diff --git a/.changeset/sw-push-session-recovery.md b/.changeset/sw-push-session-recovery.md new file mode 100644 index 000000000..646fefbdf --- /dev/null +++ b/.changeset/sw-push-session-recovery.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +fix(sw): improve push session recovery by increasing TTL, adding timeout fallback, and resetting heartbeat backoff on foreground sync diff --git a/config.json b/config.json index f0c3c8b61..3659a35d2 100644 --- a/config.json +++ b/config.json @@ -19,6 +19,11 @@ "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..0fefeff22 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,23 +1,107 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; import { useAtom } from 'jotai'; +import { getSlidingSyncManager } from '$client/initMatrix'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig } from './useClientConfig'; +import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); +const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; +const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; +const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; + export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); + const sessionSyncConfig = clientConfig.sessionSync; + const sessionSyncVariant = useExperimentVariant( + 'sessionSyncStrategy', + mx?.getUserId() ?? undefined + ); + + // Derive phase flags from experiment variant; fall back to direct config when not in experiment. + const inSessionSync = sessionSyncVariant.inExperiment; + const syncVariant = sessionSyncVariant.variant; + const phase1ForegroundResync = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase1ForegroundResync === true; + const phase2VisibleHeartbeat = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase2VisibleHeartbeat === true; + const phase3AdaptiveBackoffJitter = inSessionSync + ? syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; + + const foregroundDebounceMs = Math.max( + 0, + sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS + ); + const heartbeatIntervalMs = Math.max( + 1000, + sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS + ); + const resumeHeartbeatSuppressMs = Math.max( + 0, + sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS + ); + const heartbeatMaxBackoffMs = Math.max( + heartbeatIntervalMs, + sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS + ); + + const lastForegroundPushAtRef = useRef(0); + const suppressHeartbeatUntilRef = useRef(0); + const heartbeatFailuresRef = useRef(0); + + const pushSessionNow = useCallback( + (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { + const baseUrl = mx?.getHomeserverUrl(); + const accessToken = mx?.getAccessToken(); + const userId = mx?.getUserId(); + const canPush = + !!mx && + typeof baseUrl === 'string' && + typeof accessToken === 'string' && + typeof userId === 'string' && + 'serviceWorker' in navigator && + !!navigator.serviceWorker.controller; + + if (!canPush) { + debugLog.warn('network', 'Skipped SW session sync', { + reason, + hasClient: !!mx, + hasBaseUrl: !!baseUrl, + hasAccessToken: !!accessToken, + hasUserId: !!userId, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'skipped'; + } + + pushSessionToSW(baseUrl, accessToken, userId); + debugLog.info('network', 'Pushed session to SW', { + reason, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + }); + return 'sent'; + }, + [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] + ); + useEffect(() => { const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; @@ -26,30 +110,147 @@ export function useAppVisibility(mx: MatrixClient | undefined) { `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.onVisibilityChange?.(isVisible); + appEvents.emitVisibilityChange(isVisible); if (!isVisible) { - appEvents.onVisibilityHidden?.(); + appEvents.emitVisibilityHidden(); + return; + } + + // Always kick the sync loop on foreground regardless of phase flags — + // the SDK may be sitting in exponential backoff after iOS froze the tab. + mx?.retryImmediately(); + // retryImmediately() is a no-op on SlidingSyncSdk — call resend() on the + // SlidingSync instance directly to abort a stale long-poll and start fresh. + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('foreground') === 'sent') { + // A successful push proves the SW controller is up — reset adaptive backoff + // so the heartbeat returns to its normal interval immediately rather than + // staying on an inflated delay left over from a prior SW absence period. + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } + } + }; + + const handleFocus = () => { + if (document.visibilityState !== 'visible') return; + + // Always kick the sync loop on focus for the same reason as above. + mx?.retryImmediately(); + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('focus') === 'sent') { + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } } }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); }; - }, []); + }, [ + foregroundDebounceMs, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + resumeHeartbeatSuppressMs, + ]); useEffect(() => { - if (!mx) return; + if (!mx) return undefined; const handleVisibilityForNotifications = (isVisible: boolean) => { togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); }; - appEvents.onVisibilityChange = handleVisibilityForNotifications; - // eslint-disable-next-line consistent-return + const unsubscribe = appEvents.onVisibilityChange(handleVisibilityForNotifications); + return unsubscribe; + }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + + useEffect(() => { + if (!phase2VisibleHeartbeat) return undefined; + + // Reset adaptive backoff/suppression so a config or session change starts fresh. + heartbeatFailuresRef.current = 0; + suppressHeartbeatUntilRef.current = 0; + + let timeoutId: number | undefined; + + const getDelayMs = (): number => { + let delay = heartbeatIntervalMs; + + if (phase3AdaptiveBackoffJitter) { + const failures = heartbeatFailuresRef.current; + const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); + delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); + + // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. + const jitter = 0.8 + Math.random() * 0.4; + delay = Math.max(1000, Math.round(delay * jitter)); + } + + return delay; + }; + + const tick = () => { + const now = Date.now(); + + if (document.visibilityState !== 'visible' || !navigator.onLine) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + const result = pushSessionNow('heartbeat'); + if (phase3AdaptiveBackoffJitter) { + if (result === 'sent') { + heartbeatFailuresRef.current = 0; + } else { + // 'skipped' means prerequisites (SW controller, session) aren't ready. + // Treat as a transient failure so backoff grows until the SW is ready. + heartbeatFailuresRef.current += 1; + } + } + + timeoutId = window.setTimeout(tick, getDelayMs()); + }; + + timeoutId = window.setTimeout(tick, getDelayMs()); + return () => { - appEvents.onVisibilityChange = null; + if (timeoutId !== undefined) window.clearTimeout(timeoutId); }; - }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + }, [ + heartbeatIntervalMs, + heartbeatMaxBackoffMs, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + ]); } diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..9538ca0bf 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -5,6 +5,31 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + +export type SessionSyncConfig = { + phase1ForegroundResync?: boolean; + phase2VisibleHeartbeat?: boolean; + phase3AdaptiveBackoffJitter?: boolean; + foregroundDebounceMs?: number; + heartbeatIntervalMs?: number; + resumeHeartbeatSuppressMs?: number; + heartbeatMaxBackoffMs?: number; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -14,6 +39,10 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + + sessionSync?: SessionSyncConfig; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -55,6 +84,72 @@ export function useClientConfig(): ClientConfig { return config; } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + const enabled = experiment?.enabled === true; + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 395718223..725a4fc25 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -171,6 +171,7 @@ export function BackgroundNotifications() { const { current } = clientsRef; const activeIds = new Set(inactiveSessions.map((s) => s.userId)); + const pendingRetryTimers = new Set>(); async function sendNotification(opts: NotifyOptions): Promise { // Prefer ServiceWorkerRegistration.showNotification so that taps are handled @@ -414,6 +415,10 @@ export function BackgroundNotifications() { const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption); + // After decryption, getType() still returns the wire type (m.room.encrypted). + // Use the effective event type to get the decrypted type when available. + const effectiveEventType = mEvent.getEffectiveEvent()?.type ?? mEvent.getType(); + notifiedEventsRef.current.add(dedupeId); // Cap the set so it doesn't grow unbounded if (notifiedEventsRef.current.size > 200) { @@ -428,7 +433,7 @@ export function BackgroundNotifications() { recipientId: session.userId, previewText: resolveNotificationPreviewText({ content: mEvent.getContent(), - eventType: mEvent.getType(), + eventType: effectiveEventType, isEncryptedRoom, showMessageContent: showMessageContentRef.current, showEncryptedMessageContent: showEncryptedMessageContentRef.current, @@ -522,7 +527,8 @@ export function BackgroundNotifications() { // Retry with exponential backoff, up to 5 attempts (5s, 10s, 20s, 40s, 60s cap). if (attempt < 5) { const retryDelay = Math.min(5_000 * 2 ** attempt, 60_000); - setTimeout(() => { + const timerId = setTimeout(() => { + pendingRetryTimers.delete(timerId); const latestSession = inactiveSessionsRef.current.find( (s) => s.userId === session.userId ); @@ -530,6 +536,7 @@ export function BackgroundNotifications() { startSession(latestSession, attempt + 1); } }, retryDelay); + pendingRetryTimers.add(timerId); } }); }; @@ -539,6 +546,8 @@ export function BackgroundNotifications() { }); return () => { + pendingRetryTimers.forEach((id) => clearTimeout(id)); + pendingRetryTimers.clear(); // Reading ref.current in cleanup is intentional - we want cleanup functions // that were registered during async startBackgroundClient operations // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..f1d5e2770 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -134,7 +134,9 @@ function FaviconUpdater() { // for an OS-level app badge. if (highlightTotal > 0) { navigator.setAppBadge(highlightTotal); - } else { + } else if (document.visibilityState === 'visible') { + // Only clear when foregrounded — the SW sets the badge from push + // payloads while backgrounded, and local state may be stale. navigator.clearAppBadge(); } if (usePushNotifications) { @@ -336,7 +338,12 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + if ( + !room || + isHistoricalEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined) + ) { return; } @@ -513,8 +520,12 @@ function MessageNotifications() { }); } - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { + // In-app audio: play when the app is in the foreground (has focus) and + // notification sounds are enabled for this notification type. + // Gating on hasFocus() rather than just visibilityState prevents a race + // where the page is still 'visible' for a brief window after the user + // backgrounds the app on mobile — hasFocus() flips false first. + if (notificationSound && isLoud && document.hasFocus()) { playSound(); } }; @@ -765,7 +776,9 @@ function HandleDecryptPushEvent() { const handleMessage = async (ev: MessageEvent) => { const { data } = ev; - if (!data || data.type !== 'decryptPushEvent') return; + if (!data) return; + + if (data.type !== 'decryptPushEvent') return; const { rawEvent } = data as { rawEvent: Record }; const eventId = rawEvent.event_id as string; diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2834c5b6f..f96c016cb 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,5 +1,29 @@ +type VisibilityChangeHandler = (isVisible: boolean) => void; +type VisibilityHiddenHandler = () => void; + +const visibilityChangeHandlers = new Set(); +const visibilityHiddenHandlers = new Set(); + export const appEvents = { - onVisibilityHidden: null as (() => void) | null, + onVisibilityHidden(handler: VisibilityHiddenHandler): () => void { + visibilityHiddenHandlers.add(handler); + return () => { + visibilityHiddenHandlers.delete(handler); + }; + }, + + emitVisibilityHidden(): void { + visibilityHiddenHandlers.forEach((h) => h()); + }, + + onVisibilityChange(handler: VisibilityChangeHandler): () => void { + visibilityChangeHandlers.add(handler); + return () => { + visibilityChangeHandlers.delete(handler); + }; + }, - onVisibilityChange: null as ((isVisible: boolean) => void) | null, + emitVisibilityChange(isVisible: boolean): void { + visibilityChangeHandlers.forEach((h) => h(isVisible)); + }, }; diff --git a/src/index.tsx b/src/index.tsx index 4f2e57245..c342852b5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -93,6 +93,14 @@ if ('serviceWorker' in navigator) { log.warn('SW ready failed:', err); }); + // When the SW updates (skipWaiting + clients.claim), the old SW is killed and + // the new one has an empty sessions Map. Re-push the session immediately so + // push notifications and authenticated media fetches keep working. + navigator.serviceWorker.addEventListener('controllerchange', () => { + log.log('SW controller changed — re-sending session'); + sendSessionToSW(); + }); + navigator.serviceWorker.addEventListener('message', (ev) => { const { data } = ev; if (!data || typeof data !== 'object') return; diff --git a/src/sw.ts b/src/sw.ts index bd09cd8d3..45b760cb8 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -8,13 +8,14 @@ export type {}; declare const self: ServiceWorkerGlobalScope; let notificationSoundEnabled = true; -// Tracks whether a page client has reported itself as visible. -// The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, -// so we use this explicit flag as a fallback. -let appIsVisible = false; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; + +// Tracks whether a page client has reported itself as visible. +// Combines with clients.matchAll() in the push handler because iOS Safari PWA +// often returns empty or stale results from matchAll(). +let appIsVisible = false; const { handlePushNotificationPushData } = createPushNotifications(self, () => ({ showMessageContent, showEncryptedMessageContent, @@ -69,9 +70,12 @@ async function loadPersistedSettings() { async function persistSession(session: SessionInfo): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); + const sessionWithTimestamp = { ...session, persistedAt: Date.now() }; await cache.put( SW_SESSION_URL, - new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify(sessionWithTimestamp), { + headers: { 'Content-Type': 'application/json' }, + }) ); } catch { // Ignore — caches may be unavailable in some environments. @@ -91,13 +95,34 @@ async function loadPersistedSession(): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - const s = await response.json(); - if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + if (response) { + const s = await response.json(); + + // Reject persisted sessions older than 24 hours. Matrix access tokens are + // long-lived and are only invalidated on explicit logout or device revocation — + // not by the passage of time. A short TTL (e.g. 60 s) was too aggressive: it + // caused the SW to show generic "New Message" notifications whenever the app + // was backgrounded for more than a minute, because the cached session was + // rejected and requestSession had no live window client to reach. + // If the token truly is revoked the fetches in handleMinimalPushPayload will + // receive a 401 and gracefully fall back to a generic notification anyway. + if (typeof s.accessToken !== 'string' || typeof s.baseUrl !== 'string') { + console.debug('[SW] loadPersistedSession: invalid cached session (missing fields)'); + return undefined; + } + + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; + const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + if (age > MAX_SESSION_AGE_MS) { + console.debug('[SW] loadPersistedSession: session expired', { age }); + return undefined; + } + return { accessToken: s.accessToken, baseUrl: s.baseUrl, userId: typeof s.userId === 'string' ? s.userId : undefined, + persistedAt: s.persistedAt, }; } return undefined; @@ -111,6 +136,8 @@ type SessionInfo = { baseUrl: string; /** Matrix user ID of the account, used to identify which account a push belongs to. */ userId?: string; + /** Timestamp when this session was persisted to cache, used to expire stale tokens. */ + persistedAt?: number; }; /** @@ -369,36 +396,37 @@ async function requestDecryptionFromClient( ): Promise { const eventId = rawEvent.event_id as string; - // Chain clients sequentially using reduce to avoid await-in-loop and for-of. - return Array.from(windowClients).reduce( - async (prevPromise, client) => { - const prev = await prevPromise; - if (prev?.success) return prev; + // Try all window clients in parallel with a single shared timeout. + // This avoids the worst case of N × 5s sequential timeouts when multiple + // tabs are frozen (common on iOS). + const clientAttempts = Array.from(windowClients).map((client) => { + const promise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, resolve); + }); - const promise = new Promise((resolve) => { - decryptionPendingMap.set(eventId, resolve); - }); + try { + (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + } catch (err) { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] postMessage error', err); + return Promise.resolve(undefined as DecryptionResult | undefined); + } - const timeout = new Promise((resolve) => { - setTimeout(() => { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] timed out waiting for client', client.id); - resolve(undefined); - }, 5000); - }); + return promise as Promise; + }); - try { - (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); - } catch (err) { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] postMessage error', err); - return undefined; - } + if (clientAttempts.length === 0) return undefined; - return Promise.race([promise, timeout]); - }, - Promise.resolve(undefined) as Promise - ); + const timeout = new Promise((resolve) => { + setTimeout(() => { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] timed out waiting for all clients'); + resolve(undefined); + }, 5000); + }); + + // Return as soon as any client succeeds or the shared timeout fires. + return Promise.race([Promise.any(clientAttempts).catch(() => undefined), timeout]); } /** @@ -413,8 +441,18 @@ async function handleMinimalPushPayload( ): Promise { // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions // Map. Fall back to the Cache Storage copy that was written when the user last opened - // the app (same pattern as settings persistence). - const session = getAnyStoredSession() ?? (await loadPersistedSession()); + // the app (same pattern as settings persistence). If onPushNotification already loaded + // the persisted session into preloadedSession, reuse it to avoid a second cache read. + // Last resort: if neither the in-memory map nor the cache has a session, ask any live + // window client for a fresh token (the app may be backgrounded but still alive in memory). + let session = getAnyStoredSession() ?? preloadedSession ?? (await loadPersistedSession()); + if (!session && windowClients.length > 0) { + console.debug('[SW push] no cached session, requesting from window clients'); + const results = await Promise.all( + Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) + ); + session = results.find((r) => r != null) ?? undefined; + } if (!session) { // No session anywhere — app was never opened since install, or the user logged out. @@ -479,8 +517,10 @@ async function handleMinimalPushPayload( ? await requestDecryptionFromClient(windowClients, rawEvent) : undefined; - // If the relay responded and the app is currently visible, the in-app UI is already - // displaying the message — skip the OS notification entirely. + // If the relay responded and indicates the app is currently visible, the + // in-app UI is already displaying the message — skip the OS notification. + // result.visibilityState comes from a live postMessage round-trip, so it + // reflects the page's actual current state (not stale in-memory flags). if (result?.visibilityState === 'visible') return; if (result?.success) { @@ -495,6 +535,7 @@ async function handleMinimalPushPayload( // Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch. room_name: result.room_name || resolvedRoomName, room_avatar_url: notificationAvatarUrl, + isEncryptedRoom: true, }); } else { // App is frozen or fully closed — show "Encrypted message" fallback. @@ -555,6 +596,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); + // Keep the SW alive until the cache write completes. persistSession is + // called fire-and-forget inside setSession; without waitUntil the browser + // can kill the SW before caches.put resolves, leaving the persisted session + // stale on the next restart and causing intermittent 401s on media fetches. + const persisted = sessions.get(client.id); + event.waitUntil( + (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined) + ); event.waitUntil(cleanupDeadClients()); } if (type === 'pushDecryptResult') { @@ -604,12 +653,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const MEDIA_PATHS = [ '/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail', + '/_matrix/client/v1/media/preview_url', + '/_matrix/client/v3/media/download', + '/_matrix/client/v3/media/thumbnail', + '/_matrix/client/v3/media/preview_url', + '/_matrix/client/r0/media/download', + '/_matrix/client/r0/media/thumbnail', + '/_matrix/client/r0/media/preview_url', + '/_matrix/client/unstable/org.matrix.msc3916/media/download', + '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail', + '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url', // Legacy unauthenticated endpoints — servers that require auth return 404/403 // for these when no token is present, so intercept and add auth here too. '/_matrix/media/v3/download', '/_matrix/media/v3/thumbnail', + '/_matrix/media/v3/preview_url', '/_matrix/media/r0/download', '/_matrix/media/r0/thumbnail', + '/_matrix/media/r0/preview_url', ]; function mediaPath(url: string): boolean { @@ -628,6 +689,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +function getMatchingSessions(url: string): SessionInfo[] { + return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl)); +} + +function isAuthFailureStatus(status: number): boolean { + return status === 401 || status === 403; +} + +async function getLiveWindowSessions(url: string, clientId: string): Promise { + const collected: SessionInfo[] = []; + const seen = new Set(); + const add = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(session); + }; + + if (clientId) { + add(await requestSessionWithTimeout(clientId, 1500)); + return collected; + } + + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const liveSessions = await Promise.all( + windowClients.map((client) => requestSessionWithTimeout(client.id, 750)) + ); + liveSessions.forEach((session) => add(session)); + + return collected; +} + function fetchConfig(token: string): RequestInit { return { headers: { @@ -637,6 +731,67 @@ function fetchConfig(token: string): RequestInit { }; } +/** + * Fetch a media URL, retrying once with the most-current in-memory session on 401. + * + * There is a timing window between when the SDK refreshes its access token + * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW() + * postMessage is processed by the SW. Media requests that land in this window + * are sent with the stale token and receive 401. By the time the retry runs, + * the setSession message will normally have been processed and sessions will + * hold the new token. + * + * A second timing window exists at startup: preloadedSession may hold a stale + * token but the live setSession from the page hasn't arrived yet. In that case + * the in-memory check yields no fresher token, so we ask the live client tab + * directly (requestSessionWithTimeout) before giving up. + */ +async function fetchMediaWithRetry( + url: string, + token: string, + redirect: RequestRedirect, + clientId: string +): Promise { + let response = await fetch(url, { ...fetchConfig(token), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + + const attemptedTokens = new Set([token]); + const retrySessions: SessionInfo[] = []; + const seenSessions = new Set(); + + const addRetrySession = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seenSessions.has(key)) return; + seenSessions.add(key); + retrySessions.push(session); + }; + + if (clientId) addRetrySession(sessions.get(clientId)); + getMatchingSessions(url).forEach((session) => addRetrySession(session)); + addRetrySession(preloadedSession); + addRetrySession(await loadPersistedSession()); + (await getLiveWindowSessions(url, clientId)).forEach((session) => addRetrySession(session)); + + // Try each plausible token once. This handles token-refresh races and ambiguous + // multi-account sessions on the same homeserver, including no-clientId requests. + // Sequential await is intentional: we want to try one token at a time until one succeeds. + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < retrySessions.length; i += 1) { + const candidate = retrySessions[i]; + if (!candidate || attemptedTokens.has(candidate.accessToken)) { + // skip this candidate + } else { + attemptedTokens.add(candidate.accessToken); + response = await fetch(url, { ...fetchConfig(candidate.accessToken), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + } + } + /* eslint-enable no-await-in-loop */ + + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -667,37 +822,24 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); - return; - } - - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId)); return; } // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. + // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions. if (!clientId) { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, ''); + } + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, ''); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, ''); } return fetch(event.request); }) @@ -705,17 +847,30 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Synchronous fast-path: check in-memory sessions by baseUrl and the + // preloaded session before paying the 3-second requestSessionWithTimeout + // cost. This restores the old byBaseUrl behaviour while keeping retry logic. + const syncByBaseUrl = getMatchingSessions(url); + if (syncByBaseUrl.length === 1) { + event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId)); + return; + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId)); + return; + } + event.respondWith( requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithRetry(url, s.accessToken, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId); } console.warn( '[SW fetch] No valid session for media request', @@ -741,14 +896,19 @@ const onPushNotification = async (event: PushEvent) => { // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, , clients] = await Promise.all([ + // Capture the persisted session result into preloadedSession so that + // media fetch handlers can use it as a fallback without a second cache read. + const [, persistedSession, clients] = await Promise.all([ loadPersistedSettings(), loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); + if (persistedSession && !preloadedSession) { + preloadedSession = persistedSession; + } // If the app is open and visible, skip the OS push notification — the in-app - // pill notification handles the alert instead. + // notification handles the alert instead. // Combine clients.matchAll() visibility with the explicit appIsVisible flag // because iOS Safari PWA often returns empty or stale results from matchAll(). const hasVisibleClient = @@ -794,11 +954,40 @@ const onPushNotification = async (event: PushEvent) => { // to relay decryption to an open app tab. if (isMinimalPushPayload(pushData)) { console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); - await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + try { + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + } catch (err) { + console.error('[SW push] handleMinimalPushPayload failed:', err); + // Show a generic fallback so the user still sees something on iOS. + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: `room-${pushData.room_id}`, + renotify: true, + data: { room_id: pushData.room_id, event_id: pushData.event_id }, + } as NotificationOptions); + } return; } - await handlePushNotificationPushData(pushData); + try { + await handlePushNotificationPushData(pushData); + } catch (err) { + console.error('[SW push] handlePushNotificationPushData failed:', err); + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: pushData.room_id ? `room-${pushData.room_id}` : (pushData.event_id ?? 'Cinny'), + renotify: true, + data: { + room_id: pushData.room_id, + event_id: pushData.event_id, + user_id: pushData.user_id, + }, + } as NotificationOptions); + } }; // --------------------------------------------------------------------------- @@ -902,7 +1091,5 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { ); }); -if (self.__WB_MANIFEST) { - precacheAndRoute(self.__WB_MANIFEST); -} +precacheAndRoute(self.__WB_MANIFEST ?? []); cleanupOutdatedCaches(); diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index 1152d3d44..73bc1a495 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -88,7 +88,7 @@ export const createPushNotifications = ( previewText: resolveNotificationPreviewText({ content: pushData?.content, eventType: pushData?.type, - isEncryptedRoom: false, + isEncryptedRoom: pushData?.isEncryptedRoom === true, showMessageContent: getNotificationSettings().showMessageContent, showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent, }),