Skip to content

Commit 74d2bfc

Browse files
committed
fix: app lock trigger on navigation
1 parent 852b481 commit 74d2bfc

9 files changed

Lines changed: 96 additions & 57 deletions

File tree

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"windows": {
6464
"certificateThumbprint": null,
6565
"digestAlgorithm": "sha256",
66-
"timestampUrl": "http://timestamp.digicert.com",
66+
"timestampUrl": "https://timestamp.digicert.com",
6767
"webviewInstallMode": {
6868
"type": "downloadBootstrapper"
6969
},

src/main.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
isLockEnabled,
7272
isUnlocked,
7373
isVaultConfigured,
74+
wasUnlockedThisSession,
7475
lock as lockCryptoStore,
7576
} from './utils/crypto-store.js';
7677
import {
@@ -733,14 +734,6 @@ routeStore.subscribe((route) => {
733734
if (route === 'settings') viewModel.settingsModal.open();
734735
if (route === 'calendar') viewModel.calendarView.load();
735736
if (route === 'contacts') contactsApi.reload?.();
736-
if (mailboxMode) {
737-
if (starfieldDisposer) {
738-
starfieldDisposer();
739-
starfieldDisposer = null;
740-
}
741-
} else if (!starfieldDisposer) {
742-
starfieldDisposer = initStarfield();
743-
}
744737
});
745738

746739
function initKeyboardShortcuts() {
@@ -1286,7 +1279,11 @@ async function bootstrap() {
12861279
updateRouteVisibility(route);
12871280

12881281
// ── App Lock: show lock screen if enabled and vault is locked ──
1289-
if (isLockEnabled() && isVaultConfigured() && !isUnlocked()) {
1282+
// Skip if the user already unlocked in this tab session (survives page
1283+
// reloads within the same tab but not new tabs or tab close). The DEK
1284+
// is lost on reload but the session flag lets us silently re-prompt via
1285+
// the inactivity timer path rather than blocking the UI on every navigation.
1286+
if (isLockEnabled() && isVaultConfigured() && !isUnlocked() && !wasUnlockedThisSession()) {
12901287
await showLockScreen();
12911288
}
12921289

src/stores/mailboxActions.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { writable, derived, get } from 'svelte/store';
22
import Dexie from 'dexie';
33
import { Remote } from '../utils/remote';
4-
import { Local, Accounts } from '../utils/storage';
4+
import { Local, Session, Accounts } from '../utils/storage';
55
import { db } from '../utils/db';
66
import { mailboxStore } from './mailboxStore';
77
import { searchStore } from './searchStore';
@@ -1523,13 +1523,11 @@ export const signOut = async () => {
15231523
// Set a flag for the fallback-recovery.js to delete IndexedDB on next page load
15241524
// (the database may still be blocked by open connections on this page)
15251525
localStorage.setItem('webmail_pending_idb_cleanup', '1');
1526-
// Clear ALL localStorage (not just webmail_ prefixed keys)
1527-
// Note: setItem above will be cleared too, but that's fine — the flag
1528-
// only needs to survive until the page navigates and fallback-recovery.js reads it.
1529-
// We re-set it after clear() to ensure it persists.
1530-
localStorage.clear();
1526+
// Clear only webmail-prefixed keys (preserves third-party localStorage on same origin)
1527+
Local.clear();
1528+
Session.clear();
1529+
// Re-set cleanup flag after clear (Local.clear removes it)
15311530
localStorage.setItem('webmail_pending_idb_cleanup', '1');
1532-
sessionStorage.clear();
15331531
accounts.set([]);
15341532
currentAccount.set('');
15351533

src/svelte/LockScreen.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,10 @@
5252
const prefs = getLockPrefs();
5353
maxLength = prefs.pinLength || 6;
5454
55-
// Check if passkey is available
55+
// Check if passkey is available — show the button but don't auto-trigger
56+
// the system biometric dialog, which is intrusive on every page load.
5657
if (prefs.hasPasskey && hasPasskeyCredential() && isWebAuthnAvailable()) {
5758
showPasskeyOption = true;
58-
// Auto-trigger passkey prompt on mount
59-
setTimeout(() => handlePasskeyAuth(), 300);
6059
}
6160
6261
// Restore lockout state from sessionStorage

src/svelte/Mailbox.svelte

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -949,25 +949,27 @@ const stopVerticalResize = () => {
949949
* Outbox items may contain user-composed HTML that has not been
950950
* server-sanitized, so we must run DOMPurify before rendering.
951951
*/
952+
// Create a DOMPurify instance with the hook pre-registered so it isn't
953+
// added repeatedly on every call to sanitizeOutboxHtml.
954+
const outboxPurify = DOMPurify();
955+
outboxPurify.addHook('afterSanitizeAttributes', (node) => {
956+
// Strip all on* event handler attributes (covers onerror, onload, onclick, etc.)
957+
for (const attr of [...node.attributes]) {
958+
if (attr.name.startsWith('on')) node.removeAttribute(attr.name);
959+
}
960+
// Ensure links open safely in new tab
961+
if (node.tagName === 'A') {
962+
node.setAttribute('target', '_blank');
963+
node.setAttribute('rel', 'noopener noreferrer');
964+
}
965+
});
966+
952967
const sanitizeOutboxHtml = (html: string): string => {
953968
if (!html) return '';
954-
return DOMPurify.sanitize(html, {
969+
return outboxPurify.sanitize(html, {
955970
USE_PROFILES: { html: true },
956971
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|ftp):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
957972
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button'],
958-
HOOKS: {
959-
afterSanitizeAttributes: (node) => {
960-
// Strip all on* event handler attributes (covers onerror, onload, onclick, etc.)
961-
for (const attr of [...node.attributes]) {
962-
if (attr.name.startsWith('on')) node.removeAttribute(attr.name);
963-
}
964-
// Ensure links open safely in new tab
965-
if (node.tagName === 'A') {
966-
node.setAttribute('target', '_blank');
967-
node.setAttribute('rel', 'noopener noreferrer');
968-
}
969-
},
970-
},
971973
});
972974
};
973975

src/utils/crypto-store.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ const isSensitiveLocalKey = (key) => {
9999
return false;
100100
};
101101

102+
const SESSION_UNLOCKED_KEY = 'webmail_lock_session_unlocked';
103+
102104
let _sodium = null;
103105
let _dek = null; // Data Encryption Key - held in memory only while unlocked
104106
let _initialized = false;
@@ -286,6 +288,11 @@ async function createVault(kek) {
286288
_dek = dek;
287289
_enabled = true;
288290
_initialized = true;
291+
try {
292+
sessionStorage.setItem(SESSION_UNLOCKED_KEY, '1');
293+
} catch {
294+
// ignore
295+
}
289296
}
290297

291298
/**
@@ -312,6 +319,13 @@ async function openVault(kek) {
312319
_dek = dek;
313320
_enabled = true;
314321
_initialized = true;
322+
// Mark this tab session as unlocked so in-app page reloads
323+
// don't re-prompt (cleared on tab close by sessionStorage).
324+
try {
325+
sessionStorage.setItem(SESSION_UNLOCKED_KEY, '1');
326+
} catch {
327+
// ignore
328+
}
315329
return true;
316330
} catch {
317331
// Wrong PIN / passkey — decryption failed
@@ -359,6 +373,12 @@ function lock() {
359373
}
360374
_dek = null;
361375
}
376+
// Clear session unlock flag so the next page load shows the lock screen
377+
try {
378+
sessionStorage.removeItem(SESSION_UNLOCKED_KEY);
379+
} catch {
380+
// ignore
381+
}
362382
}
363383

364384
/**
@@ -368,6 +388,20 @@ function isUnlocked() {
368388
return _dek !== null && _enabled;
369389
}
370390

391+
/**
392+
* Check if this tab session was previously unlocked (survives page reloads
393+
* within the same tab but cleared on tab close). Used by bootstrap to
394+
* avoid re-prompting the lock screen on SPA-style page reloads where the
395+
* in-memory DEK is lost but the user already authenticated.
396+
*/
397+
function wasUnlockedThisSession() {
398+
try {
399+
return sessionStorage.getItem(SESSION_UNLOCKED_KEY) === '1';
400+
} catch {
401+
return false;
402+
}
403+
}
404+
371405
/**
372406
* Check if encryption is enabled and initialized.
373407
*/
@@ -845,6 +879,7 @@ export {
845879
isVaultConfigured,
846880
isLockEnabled,
847881
isUnlocked,
882+
wasUnlockedThisSession,
848883
isEnabled,
849884
getSodium,
850885

src/utils/sync-bridge.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,13 @@ export async function destroySyncBridge() {
143143
function _sendViaSW(payload) {
144144
if (!navigator.serviceWorker?.controller) {
145145
// SW not yet active — queue for when it is
146-
navigator.serviceWorker?.ready?.then((reg) => {
147-
reg.active?.postMessage(payload);
148-
});
146+
navigator.serviceWorker?.ready
147+
?.then((reg) => {
148+
reg.active?.postMessage(payload);
149+
})
150+
.catch((err) => {
151+
console.warn('[sync-bridge] SW ready failed:', err);
152+
});
149153
return;
150154
}
151155

src/utils/websocket-client.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import { config } from '../config';
2929
let unpack = null;
3030
let pack = null;
3131
let msgpackrAvailable = false;
32-
let msgpackrInitialized = false;
3332
let _initPromise = null;
3433

3534
/**
@@ -297,9 +296,8 @@ export function createWebSocketClient(opts = {}) {
297296
cancelReconnect();
298297

299298
// Ensure msgpackr is initialized (once) if the client wants it
300-
if (wantsMsgpackr && !msgpackrInitialized) {
299+
if (wantsMsgpackr) {
301300
await initMsgpackr();
302-
msgpackrInitialized = true;
303301
}
304302

305303
const url = buildURL();
@@ -311,15 +309,18 @@ export function createWebSocketClient(opts = {}) {
311309
console.info('[ws] Connecting...');
312310

313311
try {
314-
// Browser WebSocket doesn't support custom headers.
315-
// For Basic Auth, we encode credentials in the URL (user:pass@host).
316-
let connectURL = url;
317-
if (opts.email && opts.password) {
318-
const parsed = new URL(url);
319-
parsed.username = encodeURIComponent(opts.email);
320-
parsed.password = encodeURIComponent(opts.password);
321-
connectURL = parsed.toString();
322-
}
312+
// NOTE: Browser WebSocket API does not support custom headers, and
313+
// modern browsers no longer send an Authorization header from URL
314+
// userinfo (user:pass@host) on WebSocket upgrade requests.
315+
// The server's _authenticate() exclusively reads basicAuth(request)
316+
// from the Authorization header, so browser clients cannot currently
317+
// authenticate. Until the server is updated to accept query-param
318+
// or token-based auth for WebSocket upgrades, the connection will
319+
// fall through to broadcastOnly mode.
320+
// TODO: Update server _authenticate() to read query.username /
321+
// query.password when Authorization header is absent, then pass
322+
// credentials here via searchParams.
323+
const connectURL = url;
323324

324325
socket = new WebSocket(connectURL);
325326
if (wantsMsgpackr && msgpackrAvailable) {

src/utils/websocket-updater.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { mailboxStore } from '../stores/mailboxStore';
2525
import { Local } from './storage';
2626
import { startInitialSync } from './sync-controller';
2727
import { createWebSocketClient, createReleaseWatcher, WS_EVENTS } from './websocket-client';
28-
import { connectNotifications, requestNotificationPermission } from './notification-manager';
28+
import { connectNotifications } from './notification-manager';
2929

3030
// ── Constants ──────────────────────────────────────────────────────────────
3131
const FALLBACK_POLL_INTERVAL_MS = 300_000; // 5 min fallback — WebSocket handles real-time
@@ -128,9 +128,12 @@ function createWebSocketUpdater() {
128128
if (destroyed || started) return;
129129
started = true;
130130

131-
// Read credentials at connect time only — never store them
131+
// Read credentials at connect time only — never store them.
132+
// The app stores alias_auth as "email:password" (password may contain colons).
132133
const email = Local.get('email');
133-
const password = Local.get('password');
134+
const aliasAuth = Local.get('alias_auth') || '';
135+
const colonIdx = aliasAuth.indexOf(':');
136+
const password = colonIdx !== -1 ? aliasAuth.slice(colonIdx + 1) : '';
134137

135138
// Always start the release watcher (no auth needed)
136139
releaseWatcher = createReleaseWatcher();
@@ -264,14 +267,14 @@ function createWebSocketUpdater() {
264267
// Connect notification manager
265268
notifCleanup = connectNotifications(wsClient);
266269

267-
// Request notification permission
268-
requestNotificationPermission();
269-
270270
wsClient.connect();
271271
}
272272

273-
// Start fallback polling only when WS client exists
274-
if (wsClient) startFallbackPoll();
273+
// Start fallback polling whenever we have credentials,
274+
// even if the WebSocket connection fails to establish.
275+
if (isNonEmptyString(email) && isNonEmptyString(password)) {
276+
startFallbackPoll();
277+
}
275278
},
276279

277280
stop() {

0 commit comments

Comments
 (0)