feat(ext/sw): add session inactivity timer with configurable timeout
Implements a service-worker-side session timer that locks the vault after a configurable period of inactivity (default 15 min). Supports two modes: 'inactivity' (timer-based) and 'every_time' (no timer). Config persists via chrome.storage.local and is exposed through get_session_config / update_session_config popup messages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
99
extension/src/service-worker/__tests__/session-timer.test.ts
Normal file
99
extension/src/service-worker/__tests__/session-timer.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as timer from '../session-timer';
|
||||||
|
|
||||||
|
describe('session-timer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// Reset to default config for each test
|
||||||
|
timer.setConfig({ mode: 'inactivity', minutes: 15 });
|
||||||
|
timer.stopTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
timer.stopTimer();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires callback after inactivity timeout', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
timer.onExpired(cb);
|
||||||
|
timer.resetTimer();
|
||||||
|
|
||||||
|
// Default is 15 minutes = 900_000 ms
|
||||||
|
vi.advanceTimersByTime(899_999);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets timer on each resetTimer() call', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
timer.onExpired(cb);
|
||||||
|
timer.resetTimer();
|
||||||
|
|
||||||
|
// Advance 10 minutes
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Reset — clock should restart
|
||||||
|
timer.resetTimer();
|
||||||
|
|
||||||
|
// Advance another 10 minutes (20 total, but only 10 since last reset)
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance remaining 5 minutes to hit the 15-minute mark from last reset
|
||||||
|
vi.advanceTimersByTime(300_000);
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when mode is every_time', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
timer.onExpired(cb);
|
||||||
|
timer.setConfig({ mode: 'every_time' });
|
||||||
|
timer.resetTimer();
|
||||||
|
|
||||||
|
// Advance well past the default 15 minutes
|
||||||
|
vi.advanceTimersByTime(60 * 60 * 1000);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects updated minutes', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
timer.onExpired(cb);
|
||||||
|
timer.setConfig({ mode: 'inactivity', minutes: 5 });
|
||||||
|
timer.resetTimer();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(4 * 60 * 1000);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60 * 1000);
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getConfig returns current config', () => {
|
||||||
|
// Default
|
||||||
|
expect(timer.getConfig()).toEqual({ mode: 'inactivity', minutes: 15 });
|
||||||
|
|
||||||
|
// After set
|
||||||
|
timer.setConfig({ mode: 'every_time' });
|
||||||
|
expect(timer.getConfig()).toEqual({ mode: 'every_time' });
|
||||||
|
|
||||||
|
timer.setConfig({ mode: 'inactivity', minutes: 30 });
|
||||||
|
expect(timer.getConfig()).toEqual({ mode: 'inactivity', minutes: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stopTimer prevents firing', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
timer.onExpired(cb);
|
||||||
|
timer.resetTimer();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(600_000); // 10 minutes in
|
||||||
|
timer.stopTimer();
|
||||||
|
|
||||||
|
// Advance past what would have been the 15-minute mark
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
/// forwards every message into router/index.route().
|
/// forwards every message into router/index.route().
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
|
import type { SessionTimeoutConfig } from '../shared/messages';
|
||||||
import type { RouterState } from './router/index';
|
import type { RouterState } from './router/index';
|
||||||
import { route } from './router/index';
|
import { route } from './router/index';
|
||||||
import * as vault from './vault';
|
import * as vault from './vault';
|
||||||
|
import { clearCurrent } from './session';
|
||||||
|
import * as sessionTimer from './session-timer';
|
||||||
|
|
||||||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||||||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||||||
@@ -43,9 +46,28 @@ const state: RouterState = {
|
|||||||
wasm: null,
|
wasm: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Session timer wiring ---
|
||||||
|
|
||||||
|
sessionTimer.onExpired(() => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[relicario sw] session expired — locking vault');
|
||||||
|
clearCurrent();
|
||||||
|
state.manifest = null;
|
||||||
|
// Best-effort broadcast — receiver may not exist yet.
|
||||||
|
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore saved session config from chrome.storage.local on SW startup.
|
||||||
|
chrome.storage.local.get('sessionTimeoutConfig').then((r) => {
|
||||||
|
if (r.sessionTimeoutConfig) {
|
||||||
|
sessionTimer.setConfig(r.sessionTimeoutConfig as SessionTimeoutConfig);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener(
|
chrome.runtime.onMessage.addListener(
|
||||||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
sessionTimer.resetTimer();
|
||||||
if (!state.wasm) {
|
if (!state.wasm) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[relicario sw] initializing WASM on first message');
|
console.log('[relicario sw] initializing WASM on first message');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createGitHost, base64ToUint8Array } from '../git-host';
|
|||||||
import * as vault from '../vault';
|
import * as vault from '../vault';
|
||||||
import * as session from '../session';
|
import * as session from '../session';
|
||||||
import * as devices from '../devices';
|
import * as devices from '../devices';
|
||||||
|
import * as sessionTimer from '../session-timer';
|
||||||
|
|
||||||
// --- Shared ambient state owned by the SW module ---
|
// --- Shared ambient state owned by the SW module ---
|
||||||
//
|
//
|
||||||
@@ -353,6 +354,16 @@ export async function handle(
|
|||||||
const history = state.wasm.get_field_history(JSON.stringify(item));
|
const history = state.wasm.get_field_history(JSON.stringify(item));
|
||||||
return { ok: true, data: { history } };
|
return { ok: true, data: { history } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'get_session_config':
|
||||||
|
return { ok: true, data: { config: sessionTimer.getConfig() } };
|
||||||
|
|
||||||
|
case 'update_session_config': {
|
||||||
|
sessionTimer.setConfig(msg.config);
|
||||||
|
sessionTimer.resetTimer();
|
||||||
|
await chrome.storage.local.set({ sessionTimeoutConfig: msg.config });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
extension/src/service-worker/session-timer.ts
Normal file
50
extension/src/service-worker/session-timer.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/// Session inactivity timer.
|
||||||
|
///
|
||||||
|
/// Two modes:
|
||||||
|
/// - `inactivity`: fires the expiry callback after N minutes of no
|
||||||
|
/// resetTimer() calls (i.e. no popup/vault-tab messages).
|
||||||
|
/// - `every_time`: no timer — the session is cleared on every popup close
|
||||||
|
/// (handled elsewhere). resetTimer() is a no-op.
|
||||||
|
|
||||||
|
import type { SessionTimeoutConfig } from '../shared/messages';
|
||||||
|
|
||||||
|
let config: SessionTimeoutConfig = { mode: 'inactivity', minutes: 15 };
|
||||||
|
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let expiredCallback: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** Register the callback invoked when the inactivity timer fires. */
|
||||||
|
export function onExpired(cb: () => void): void {
|
||||||
|
expiredCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the current session timeout config. */
|
||||||
|
export function getConfig(): SessionTimeoutConfig {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the config. Also stops any running timer (caller should
|
||||||
|
* resetTimer() afterwards if the session is still active). */
|
||||||
|
export function setConfig(c: SessionTimeoutConfig): void {
|
||||||
|
config = c;
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear and restart the inactivity timer. No-op when mode is `every_time`. */
|
||||||
|
export function resetTimer(): void {
|
||||||
|
stopTimer();
|
||||||
|
if (config.mode !== 'inactivity') return;
|
||||||
|
|
||||||
|
const ms = config.minutes * 60 * 1000;
|
||||||
|
timerId = setTimeout(() => {
|
||||||
|
timerId = null;
|
||||||
|
if (expiredCallback) expiredCallback();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel any pending timer without changing config. */
|
||||||
|
export function stopTimer(): void {
|
||||||
|
if (timerId !== null) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import type {
|
|||||||
FieldHistoryView,
|
FieldHistoryView,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
// --- Session timeout config ---
|
||||||
|
|
||||||
|
export type SessionTimeoutConfig =
|
||||||
|
| { mode: 'inactivity'; minutes: number }
|
||||||
|
| { mode: 'every_time' };
|
||||||
|
|
||||||
// --- Messages a popup (or setup page) may send ---
|
// --- Messages a popup (or setup page) may send ---
|
||||||
|
|
||||||
export type PopupMessage =
|
export type PopupMessage =
|
||||||
@@ -39,7 +45,9 @@ export type PopupMessage =
|
|||||||
| { type: 'restore_item'; id: ItemId }
|
| { type: 'restore_item'; id: ItemId }
|
||||||
| { type: 'purge_item'; id: ItemId }
|
| { type: 'purge_item'; id: ItemId }
|
||||||
| { type: 'purge_all_trash' }
|
| { type: 'purge_all_trash' }
|
||||||
| { type: 'get_field_history'; id: ItemId };
|
| { type: 'get_field_history'; id: ItemId }
|
||||||
|
| { type: 'get_session_config' }
|
||||||
|
| { type: 'update_session_config'; config: SessionTimeoutConfig };
|
||||||
|
|
||||||
// --- Messages a content script may send ---
|
// --- Messages a content script may send ---
|
||||||
|
|
||||||
@@ -143,6 +151,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
|||||||
'list_devices', 'add_device', 'revoke_device',
|
'list_devices', 'add_device', 'revoke_device',
|
||||||
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
||||||
'get_field_history',
|
'get_field_history',
|
||||||
|
'get_session_config', 'update_session_config',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user