Skip to content
This repository was archived by the owner on Apr 6, 2026. It is now read-only.

Commit c5897dc

Browse files
jjohareclaude
andcommitted
feat: PWA-first experience with persistent login and aggressive caching
PWA users now get a frictionless native app experience: - Persistent login: PWA users stay logged in permanently across sessions - Credentials stored in PWA-specific localStorage (nostr_bbs_pwa_auth) - No session key encryption for PWA mode (avoids session expiry issues) - Automatic detection of PWA mode via display-mode media queries - Session timeout disabled for PWA users - PWA users never get auto-logged out due to inactivity - Browser users still have 30-minute session timeout for security - Enhanced service worker caching (v2) - Aggressive cache-first strategy for all static assets - Separate caches for fonts, profiles, API responses - Cache size limits to prevent storage bloat - Stale-while-revalidate for app shell routes - Automatic update checking - Periodic checks (1h for PWA, 4h for browser) - Checks on visibility change (when user returns to app) - Proper updatefound event handling per MDN spec 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9ecbdcd commit c5897dc

4 files changed

Lines changed: 382 additions & 32 deletions

File tree

src/lib/stores/auth.ts

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { writable, derived } from 'svelte/store';
1+
import { writable, derived, get } from 'svelte/store';
22
import type { Writable } from 'svelte/store';
33
import { browser } from '$app/environment';
44
import { base } from '$app/paths';
55
import { encryptPrivateKey, decryptPrivateKey, isEncryptionAvailable } from '$lib/utils/key-encryption';
6+
import { isPWAInstalled, checkIfPWA } from '$lib/stores/pwa';
67

78
export interface AuthState {
89
state: 'unauthenticated' | 'authenticating' | 'authenticated';
@@ -40,6 +41,16 @@ const STORAGE_KEY = 'nostr_bbs_keys';
4041
const SESSION_KEY = 'nostr_bbs_session';
4142
const COOKIE_KEY = 'nostr_bbs_auth';
4243
const KEEP_SIGNED_IN_KEY = 'nostr_bbs_keep_signed_in';
44+
const PWA_AUTH_KEY = 'nostr_bbs_pwa_auth';
45+
46+
/**
47+
* Check if running as installed PWA
48+
*/
49+
function isRunningAsPWA(): boolean {
50+
if (!browser) return false;
51+
// Check current state or stored PWA mode
52+
return get(isPWAInstalled) || checkIfPWA() || localStorage.getItem('nostr_bbs_pwa_mode') === 'true';
53+
}
4354

4455
/**
4556
* Cookie utilities for persistent auth
@@ -113,6 +124,33 @@ function createAuthStore() {
113124
return;
114125
}
115126

127+
// Check for PWA persistent auth first (no session expiry)
128+
const pwaAuth = localStorage.getItem(PWA_AUTH_KEY);
129+
if (pwaAuth && isRunningAsPWA()) {
130+
try {
131+
const pwaData = JSON.parse(pwaAuth);
132+
if (pwaData.publicKey && pwaData.privateKey) {
133+
console.log('[Auth] Restoring PWA persistent session');
134+
update(state => ({
135+
...state,
136+
...syncStateFields({
137+
publicKey: pwaData.publicKey,
138+
privateKey: pwaData.privateKey,
139+
nickname: pwaData.nickname || null,
140+
avatar: pwaData.avatar || null,
141+
isAuthenticated: true,
142+
isEncrypted: false,
143+
mnemonicBackedUp: pwaData.mnemonicBackedUp || false,
144+
isReady: true
145+
})
146+
}));
147+
return;
148+
}
149+
} catch {
150+
// Invalid PWA auth data, continue with normal flow
151+
}
152+
}
153+
116154
const stored = localStorage.getItem(STORAGE_KEY);
117155
if (!stored) {
118156
update(state => ({ ...state, ...syncStateFields({ isReady: true }) }));
@@ -142,18 +180,35 @@ function createAuthStore() {
142180
}));
143181
} catch {
144182
// Session key changed (new session) - need to re-authenticate
145-
update(state => ({
146-
...state,
147-
...syncStateFields({
148-
publicKey: parsed.publicKey,
149-
nickname: parsed.nickname || null,
150-
avatar: parsed.avatar || null,
151-
isAuthenticated: false,
152-
isEncrypted: true,
153-
error: 'Session expired. Please enter your password to unlock.',
154-
isReady: true
155-
})
156-
}));
183+
// But if we're in PWA mode, try to use the stored keys directly
184+
if (isRunningAsPWA() && parsed.publicKey) {
185+
// For PWA, prompt user to re-enter password once to migrate
186+
update(state => ({
187+
...state,
188+
...syncStateFields({
189+
publicKey: parsed.publicKey,
190+
nickname: parsed.nickname || null,
191+
avatar: parsed.avatar || null,
192+
isAuthenticated: false,
193+
isEncrypted: true,
194+
error: 'Please unlock to enable persistent PWA login.',
195+
isReady: true
196+
})
197+
}));
198+
} else {
199+
update(state => ({
200+
...state,
201+
...syncStateFields({
202+
publicKey: parsed.publicKey,
203+
nickname: parsed.nickname || null,
204+
avatar: parsed.avatar || null,
205+
isAuthenticated: false,
206+
isEncrypted: true,
207+
error: 'Session expired. Please enter your password to unlock.',
208+
isReady: true
209+
})
210+
}));
211+
}
157212
}
158213
} else if (parsed.privateKey) {
159214
// Legacy unencrypted data - migrate on next save
@@ -240,6 +295,19 @@ function createAuthStore() {
240295
if (shouldKeepSignedIn()) {
241296
setCookie(COOKIE_KEY, publicKey, 30); // 30 day cookie
242297
}
298+
299+
// For PWA mode: store persistent auth that survives session changes
300+
if (isRunningAsPWA()) {
301+
const pwaAuthData = {
302+
publicKey,
303+
privateKey,
304+
nickname: existingData.nickname || null,
305+
avatar: existingData.avatar || null,
306+
mnemonicBackedUp: existingData.mnemonicBackedUp || false
307+
};
308+
localStorage.setItem(PWA_AUTH_KEY, JSON.stringify(pwaAuthData));
309+
console.log('[Auth] PWA persistent auth stored');
310+
}
243311
}
244312

245313
update(state => ({ ...state, ...syncStateFields(authData) }));
@@ -289,6 +357,19 @@ function createAuthStore() {
289357
parsed.encryptedPrivateKey = newEncrypted;
290358
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed));
291359

360+
// For PWA mode: store persistent auth that survives session changes
361+
if (isRunningAsPWA()) {
362+
const pwaAuthData = {
363+
publicKey: parsed.publicKey,
364+
privateKey,
365+
nickname: parsed.nickname || null,
366+
avatar: parsed.avatar || null,
367+
mnemonicBackedUp: parsed.mnemonicBackedUp || false
368+
};
369+
localStorage.setItem(PWA_AUTH_KEY, JSON.stringify(pwaAuthData));
370+
console.log('[Auth] PWA persistent auth stored after unlock');
371+
}
372+
292373
update(state => ({
293374
...state,
294375
...syncStateFields({
@@ -337,6 +418,7 @@ function createAuthStore() {
337418
set(initialState);
338419
if (browser) {
339420
localStorage.removeItem(STORAGE_KEY);
421+
localStorage.removeItem(PWA_AUTH_KEY);
340422
sessionStorage.removeItem(SESSION_KEY);
341423
deleteCookie(COOKIE_KEY);
342424
const { goto } = await import('$app/navigation');

src/lib/stores/pwa.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ export const swRegistration = writable<ServiceWorkerRegistration | null>(null);
3232
*/
3333
export const isPWAInstalled = writable<boolean>(false);
3434

35+
/**
36+
* Check if app is running as installed PWA
37+
*/
38+
export function checkIfPWA(): boolean {
39+
if (typeof window === 'undefined') return false;
40+
41+
// Check display-mode media query (most reliable)
42+
if (window.matchMedia('(display-mode: standalone)').matches) return true;
43+
if (window.matchMedia('(display-mode: fullscreen)').matches) return true;
44+
if (window.matchMedia('(display-mode: minimal-ui)').matches) return true;
45+
46+
// Check iOS Safari standalone mode
47+
if ((navigator as Navigator & { standalone?: boolean }).standalone === true) return true;
48+
49+
// Check if launched from home screen on Android
50+
if (document.referrer.includes('android-app://')) return true;
51+
52+
return false;
53+
}
54+
55+
/**
56+
* PWA mode storage key
57+
*/
58+
const PWA_MODE_KEY = 'nostr_bbs_pwa_mode';
59+
3560
/**
3661
* Message queue count
3762
*/
@@ -70,11 +95,15 @@ export function initPWA(): void {
7095
window.addEventListener('appinstalled', () => {
7196
isPWAInstalled.set(true);
7297
installPrompt.set(null);
98+
// Persist PWA mode for future sessions
99+
localStorage.setItem(PWA_MODE_KEY, 'true');
73100
});
74101

75-
// Check if running as PWA
76-
if (window.matchMedia('(display-mode: standalone)').matches) {
102+
// Check if running as PWA (current session or previously installed)
103+
const isPWA = checkIfPWA() || localStorage.getItem(PWA_MODE_KEY) === 'true';
104+
if (isPWA) {
77105
isPWAInstalled.set(true);
106+
localStorage.setItem(PWA_MODE_KEY, 'true');
78107
}
79108

80109
// Listen for service worker messages
@@ -116,6 +145,12 @@ export async function triggerInstall(): Promise<boolean> {
116145
}
117146
}
118147

148+
// Update check interval (1 hour for PWA, 4 hours for browser)
149+
const UPDATE_CHECK_INTERVAL_PWA = 60 * 60 * 1000; // 1 hour
150+
const UPDATE_CHECK_INTERVAL_BROWSER = 4 * 60 * 60 * 1000; // 4 hours
151+
152+
let updateCheckInterval: ReturnType<typeof setInterval> | null = null;
153+
119154
/**
120155
* Register service worker
121156
*/
@@ -135,28 +170,81 @@ export async function registerServiceWorker(): Promise<ServiceWorkerRegistration
135170
console.log('[PWA] Service worker registered:', registration);
136171
swRegistration.set(registration);
137172

138-
// Check for updates
173+
// Listen for updatefound event (per MDN spec)
139174
registration.addEventListener('updatefound', () => {
140175
const newWorker = registration.installing;
141176

142177
if (!newWorker) {
143178
return;
144179
}
145180

181+
console.log('[PWA] New service worker installing...');
182+
146183
newWorker.addEventListener('statechange', () => {
147-
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
148-
updateAvailable.set(true);
184+
if (newWorker.state === 'installed') {
185+
if (navigator.serviceWorker.controller) {
186+
// New update available
187+
console.log('[PWA] New version available');
188+
updateAvailable.set(true);
189+
} else {
190+
// First install
191+
console.log('[PWA] Service worker installed for the first time');
192+
}
149193
}
150194
});
151195
});
152196

197+
// Start periodic update checks
198+
startUpdateChecks(registration);
199+
153200
return registration;
154201
} catch (error) {
155202
console.error('[PWA] Service worker registration failed:', error);
156203
return null;
157204
}
158205
}
159206

207+
/**
208+
* Start periodic update checks
209+
*/
210+
function startUpdateChecks(registration: ServiceWorkerRegistration): void {
211+
// Clear any existing interval
212+
if (updateCheckInterval) {
213+
clearInterval(updateCheckInterval);
214+
}
215+
216+
// Use shorter interval for PWA mode
217+
const isPWA = checkIfPWA() || localStorage.getItem(PWA_MODE_KEY) === 'true';
218+
const interval = isPWA ? UPDATE_CHECK_INTERVAL_PWA : UPDATE_CHECK_INTERVAL_BROWSER;
219+
220+
// Check for updates periodically
221+
updateCheckInterval = setInterval(() => {
222+
checkForUpdates(registration);
223+
}, interval);
224+
225+
// Also check on visibility change (when user returns to app)
226+
document.addEventListener('visibilitychange', () => {
227+
if (document.visibilityState === 'visible') {
228+
checkForUpdates(registration);
229+
}
230+
});
231+
}
232+
233+
/**
234+
* Manually check for service worker updates
235+
*/
236+
export async function checkForUpdates(registration?: ServiceWorkerRegistration): Promise<void> {
237+
const reg = registration || get(swRegistration);
238+
if (!reg) return;
239+
240+
try {
241+
await reg.update();
242+
console.log('[PWA] Update check completed');
243+
} catch (error) {
244+
console.error('[PWA] Update check failed:', error);
245+
}
246+
}
247+
160248
/**
161249
* Update service worker
162250
*/

src/lib/stores/session.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
*
44
* Implements automatic session timeout for security.
55
* Tracks user activity and auto-logouts after inactivity period.
6+
*
7+
* NOTE: Session timeout is DISABLED for PWA users to provide
8+
* a frictionless native app experience.
69
*/
710

811
import { writable, get } from 'svelte/store';
912
import { browser } from '$app/environment';
13+
import { isPWAInstalled, checkIfPWA } from '$lib/stores/pwa';
1014

1115
// Session timeout configuration (milliseconds)
1216
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes of inactivity
@@ -97,12 +101,34 @@ function createSessionStore() {
97101
}
98102
}
99103

104+
/**
105+
* Check if running as installed PWA
106+
*/
107+
function isRunningAsPWA(): boolean {
108+
if (!browser) return false;
109+
return get(isPWAInstalled) || checkIfPWA() || localStorage.getItem('nostr_bbs_pwa_mode') === 'true';
110+
}
111+
100112
/**
101113
* Start session monitoring
114+
* NOTE: Session timeout is DISABLED for PWA users
102115
*/
103116
function start(onTimeout: () => void) {
104117
if (!browser) return;
105118

119+
// PWA users never get logged out due to inactivity
120+
if (isRunningAsPWA()) {
121+
console.log('[Session] PWA mode detected - session timeout disabled');
122+
update(state => ({
123+
...state,
124+
isActive: true,
125+
showWarning: false,
126+
remainingMs: SESSION_TIMEOUT_MS
127+
}));
128+
// Return no-op cleanup function
129+
return () => {};
130+
}
131+
106132
onTimeoutCallback = onTimeout;
107133

108134
// Initialize last activity

0 commit comments

Comments
 (0)