diff --git a/apps/browser-extension/src/entrypoints/background.ts b/apps/browser-extension/src/entrypoints/background.ts index 2f45a4ae4..f3f87f221 100644 --- a/apps/browser-extension/src/entrypoints/background.ts +++ b/apps/browser-extension/src/entrypoints/background.ts @@ -4,7 +4,7 @@ import { onMessage, sendMessage } from "webext-bridge/background"; -import { handleResetAutoLockTimer, handlePopupHeartbeat, handleSetAutoLockTimeout } from '@/entrypoints/background/AutolockTimeoutHandler'; +import { handleResetAutoLockTimer, handlePopupHeartbeat, handleSetAutoLockTimeout, initializeAutoLockAlarm, handleAutoLockAlarm } from '@/entrypoints/background/AutolockTimeoutHandler'; import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardClearTimeout, handleSetClipboardClearTimeout, handleGetClipboardCountdownState } from '@/entrypoints/background/ClipboardClearHandler'; import { setupContextMenus } from '@/entrypoints/background/ContextMenu'; import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handlePasskeyPopupResponse, handleGetRequestData } from '@/entrypoints/background/PasskeyHandler'; @@ -89,6 +89,15 @@ export default defineBackground({ await setupContextMenus(); } + /* + * Initialize auto-lock alarm system. + * This ensures the alarm is restored if the service worker was terminated. + */ + await initializeAutoLockAlarm(); + + // Register alarm listener for auto-lock + browser.alarms.onAlarm.addListener(handleAutoLockAlarm); + // Listen for custom commands try { browser.commands.onCommand.addListener(async (command) => { diff --git a/apps/browser-extension/src/entrypoints/background/AutolockTimeoutHandler.ts b/apps/browser-extension/src/entrypoints/background/AutolockTimeoutHandler.ts index adac1ee59..d28354b56 100644 --- a/apps/browser-extension/src/entrypoints/background/AutolockTimeoutHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/AutolockTimeoutHandler.ts @@ -1,78 +1,170 @@ -import { storage } from 'wxt/utils/storage'; - import { handleLockVault } from '@/entrypoints/background/VaultMessageHandler'; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; -let autoLockTimer: NodeJS.Timeout | null = null; +import type { Browser } from 'wxt/browser'; + +import { browser, storage } from '#imports'; + +const AUTO_LOCK_ALARM_NAME = 'vault-auto-lock'; + +/* + * Threshold in seconds below which we use setTimeout instead of alarms. + * Alarms have a minimum delay of 30 seconds in production (packed) extensions. + * For short timeouts, setTimeout is more accurate and the service worker + * won't terminate before the timer fires. + */ +const SHORT_TIMEOUT_THRESHOLD = 30; + +// Timer handle for short timeouts using setTimeout +let shortTimeoutTimer: ReturnType | null = null; /** - * Reset the auto-lock timer. + * Lock the vault due to inactivity timeout. */ -export function handleResetAutoLockTimer(): void { - resetAutoLockTimer(); +async function lockVaultDueToInactivity(): Promise { + // Check if vault is still unlocked before locking + const encryptionKey = await storage.getItem('session:encryptionKey') as string | null; + if (!encryptionKey) { + // Vault is already locked + return; + } + + try { + handleLockVault(); + console.info('[AUTO_LOCK] Vault locked due to inactivity'); + } catch (error) { + console.error('[AUTO_LOCK] Error locking vault:', error); + } } /** - * Handle popup heartbeat - extend auto-lock timer. + * Clear the short timeout timer if it exists. */ -export function handlePopupHeartbeat(): void { - extendAutoLockTimer(); +function clearShortTimeoutTimer(): void { + if (shortTimeoutTimer) { + clearTimeout(shortTimeoutTimer); + shortTimeoutTimer = null; + } } /** - * Set the auto-lock timeout setting. + * Set the auto-lock timer using the appropriate method based on timeout duration. + * Uses setTimeout for short timeouts (< 30s) and alarms for longer ones. */ -export async function handleSetAutoLockTimeout(timeout: number): Promise { - await LocalPreferencesService.setAutoLockTimeout(timeout); - resetAutoLockTimer(); - return true; +async function setAutoLockTimer(timeoutSeconds: number): Promise { + // Clear any existing timers + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); + + if (timeoutSeconds < SHORT_TIMEOUT_THRESHOLD) { + /* + * Use setTimeout for short timeouts. + * Service worker won't terminate before the timer fires. + */ + shortTimeoutTimer = setTimeout(() => { + shortTimeoutTimer = null; + lockVaultDueToInactivity(); + }, timeoutSeconds * 1000); + } else { + /* + * Use alarms for longer timeouts. + * Alarms persist across service worker restarts. + */ + const delayInMinutes = timeoutSeconds / 60; + await browser.alarms.create(AUTO_LOCK_ALARM_NAME, { + delayInMinutes: delayInMinutes + }); + } } /** - * Reset the auto-lock timer based on current settings. + * Initialize the auto-lock alarm system. + * This should be called when the background script starts. + * It checks if the vault is unlocked and if so, ensures a timer is set. */ -async function resetAutoLockTimer(): Promise { - // Clear existing timer - if (autoLockTimer) { - clearTimeout(autoLockTimer); - autoLockTimer = null; +export async function initializeAutoLockAlarm(): Promise { + // Check if vault is unlocked + const encryptionKey = await storage.getItem('session:encryptionKey') as string | null; + if (!encryptionKey) { + // Vault is locked, clear any existing alarm + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); + return; } // Get timeout setting const timeout = await LocalPreferencesService.getAutoLockTimeout(); - - // Don't set timer if timeout is 0 (disabled) or if vault is already locked if (timeout === 0) { + // Auto-lock disabled, clear any existing alarm + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); + return; + } + + /* + * For short timeouts, we can't restore the exact remaining time after + * service worker restart, so we just set a new timer with the full duration. + * For alarms, check if one already exists to avoid resetting the countdown. + */ + if (timeout < SHORT_TIMEOUT_THRESHOLD) { + // Short timeout - set a new setTimeout (can't persist across restarts anyway) + if (!shortTimeoutTimer) { + await setAutoLockTimer(timeout); + } + } else { + // Long timeout - only create alarm if one doesn't exist + const existingAlarm = await browser.alarms.get(AUTO_LOCK_ALARM_NAME); + if (!existingAlarm) { + await setAutoLockTimer(timeout); + } + } +} + +/** + * Handle the auto-lock alarm firing. + * This is called by the alarm listener in background.ts. + */ +export async function handleAutoLockAlarm(alarm: Browser.alarms.Alarm): Promise { + if (alarm.name !== AUTO_LOCK_ALARM_NAME) { + return; + } + + await lockVaultDueToInactivity(); +} + +/** + * Reset the auto-lock timer. + * This clears any existing timer and creates a new one with the full timeout period. + */ +export async function handleResetAutoLockTimer(): Promise { + // Get timeout setting + const timeout = await LocalPreferencesService.getAutoLockTimeout(); + + // Don't set timer if timeout is 0 (disabled) + if (timeout === 0) { + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); return; } // Check if vault is unlocked before setting timer const encryptionKey = await storage.getItem('session:encryptionKey') as string | null; - if (!encryptionKey) { // Vault is already locked, don't start timer + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); return; } - // Set new timer - autoLockTimer = setTimeout(async () => { - try { - handleLockVault(); - - console.info('[AUTO_LOCK] Vault locked due to inactivity'); - autoLockTimer = null; - } catch (error) { - console.error('[AUTO_LOCK] Error locking vault:', error); - } - }, timeout * 1000); + await setAutoLockTimer(timeout); } /** - * Extend the auto-lock timer by the full timeout period. - * This is called by popup heartbeats to prevent locking while popup is active. + * Handle popup heartbeat - extend auto-lock timer. + * This resets the timer to prevent locking while popup is active. */ -async function extendAutoLockTimer(): Promise { +export async function handlePopupHeartbeat(): Promise { // Get timeout setting const timeout = await LocalPreferencesService.getAutoLockTimeout(); @@ -83,28 +175,46 @@ async function extendAutoLockTimer(): Promise { // Check if vault is unlocked const encryptionKey = await storage.getItem('session:encryptionKey') as string | null; - if (!encryptionKey) { // Vault is already locked, don't extend timer return; } - // Clear existing timer and start a new one - if (autoLockTimer) { - clearTimeout(autoLockTimer); - autoLockTimer = null; + await setAutoLockTimer(timeout); +} + +/** + * Set the auto-lock timeout setting. + * Updates the stored preference and resets the timer with the new value. + */ +export async function handleSetAutoLockTimeout(timeout: number): Promise { + await LocalPreferencesService.setAutoLockTimeout(timeout); + + // Clear existing timers + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); + + // If timeout is 0 (disabled), we're done + if (timeout === 0) { + return true; } - // Set new timer - autoLockTimer = setTimeout(async () => { - try { - // Lock the vault (preserves local data for offline unlock) - handleLockVault(); + // Check if vault is unlocked before setting new timer + const encryptionKey = await storage.getItem('session:encryptionKey') as string | null; + if (!encryptionKey) { + // Vault is locked, don't start timer + return true; + } - console.info('[AUTO_LOCK] Vault locked due to inactivity'); - autoLockTimer = null; - } catch (error) { - console.error('[AUTO_LOCK] Error locking vault:', error); - } - }, timeout * 1000); + await setAutoLockTimer(timeout); + return true; +} + +/** + * Clear the auto-lock alarm and any short timeout timer. + * This should be called when the vault is locked. + */ +export async function clearAutoLockAlarm(): Promise { + clearShortTimeoutTimer(); + await browser.alarms.clear(AUTO_LOCK_ALARM_NAME); } diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts index 1cbb96587..c93df8691 100644 --- a/apps/browser-extension/wxt.config.ts +++ b/apps/browser-extension/wxt.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ "activeTab", "contextMenus", "scripting", - "clipboardWrite" + "clipboardWrite", + "alarms" ]; // Only add offscreen permission for Chrome and Edge