diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index b2ee66f051..4c6e99f78d 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -591,4 +591,24 @@ export class BrowserApi { } }); } + + static clearAlarm(alarmName: string): Promise { + return new Promise((resolve) => chrome.alarms.clear(alarmName, resolve)); + } + + static clearAllAlarms(): Promise { + return new Promise((resolve) => chrome.alarms.clearAll(resolve)); + } + + static createAlarm(name: string, createInfo: chrome.alarms.AlarmCreateInfo): Promise { + return new Promise((resolve) => chrome.alarms.create(name, createInfo, resolve)); + } + + static getAlarm(alarmName: string): Promise { + return new Promise((resolve) => chrome.alarms.get(alarmName, resolve)); + } + + static getAllAlarms(): Promise { + return new Promise((resolve) => chrome.alarms.getAll(resolve)); + } } diff --git a/apps/browser/src/platform/services/abstractions/browser-task-scheduler.service.ts b/apps/browser/src/platform/services/abstractions/browser-task-scheduler.service.ts new file mode 100644 index 0000000000..9a91a0119c --- /dev/null +++ b/apps/browser/src/platform/services/abstractions/browser-task-scheduler.service.ts @@ -0,0 +1,12 @@ +import { TaskSchedulerService } from "@bitwarden/common/platform/abstractions/task-scheduler.service"; +import { ScheduledTaskName } from "@bitwarden/common/platform/enums/scheduled-task-name.enum"; + +export type ActiveAlarm = { + name: ScheduledTaskName; + startTime: number; + createInfo: chrome.alarms.AlarmCreateInfo; +}; + +export interface BrowserTaskSchedulerService extends TaskSchedulerService { + clearAllScheduledTasks(): Promise; +} diff --git a/apps/browser/src/platform/services/browser-task-scheduler.service.ts b/apps/browser/src/platform/services/browser-task-scheduler.service.ts new file mode 100644 index 0000000000..9cef69e68d --- /dev/null +++ b/apps/browser/src/platform/services/browser-task-scheduler.service.ts @@ -0,0 +1,206 @@ +import { firstValueFrom, map, Observable } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { TaskIdentifier } from "@bitwarden/common/platform/abstractions/task-scheduler.service"; +import { ScheduledTaskName } from "@bitwarden/common/platform/enums/scheduled-task-name.enum"; +import { TaskSchedulerService } from "@bitwarden/common/platform/services/task-scheduler.service"; +import { + SCHEDULED_TASKS_DISK, + GlobalState, + KeyDefinition, + StateProvider, +} from "@bitwarden/common/platform/state"; + +import { BrowserApi } from "../browser/browser-api"; + +import { + ActiveAlarm, + BrowserTaskSchedulerService as BrowserTaskSchedulerServiceInterface, +} from "./abstractions/browser-task-scheduler.service"; + +const ACTIVE_ALARMS = new KeyDefinition(SCHEDULED_TASKS_DISK, "activeAlarms", { + deserializer: (value: ActiveAlarm[]) => value ?? [], +}); + +export class BrowserTaskSchedulerService + extends TaskSchedulerService + implements BrowserTaskSchedulerServiceInterface +{ + private activeAlarmsState: GlobalState; + readonly activeAlarms$: Observable; + private recoveredAlarms: Set = new Set(); + private onAlarmHandlers: Record void> = {}; + + constructor( + private logService: LogService, + private stateProvider: StateProvider, + ) { + super(); + + this.activeAlarmsState = this.stateProvider.getGlobal(ACTIVE_ALARMS); + this.activeAlarms$ = this.activeAlarmsState.state$.pipe( + map((activeAlarms) => activeAlarms ?? []), + ); + + this.setupOnAlarmListener(); + this.verifyAlarmsState().catch((e) => this.logService.error(e)); + } + + async setTimeout( + callback: () => void, + delayInMs: number, + taskName?: ScheduledTaskName, + ): Promise { + const delayInMinutes = delayInMs / 1000 / 60; + if (delayInMinutes < 1) { + return super.setTimeout(callback, delayInMs); + } + + this.registerAlarmHandler(taskName, callback); + if (this.recoveredAlarms.has(taskName)) { + await this.triggerRecoveredAlarm(taskName); + return; + } + + await this.createAlarm(taskName, { delayInMinutes }); + } + + async setInterval( + callback: () => void, + intervalInMs: number, + taskName?: ScheduledTaskName, + initialDelayInMs?: number, + ): Promise { + const intervalInMinutes = intervalInMs / 1000 / 60; + if (intervalInMinutes < 1) { + return super.setInterval(callback, intervalInMs); + } + + this.registerAlarmHandler(taskName, callback); + if (this.recoveredAlarms.has(taskName)) { + await this.triggerRecoveredAlarm(taskName); + } + + const initialDelayInMinutes = initialDelayInMs ? initialDelayInMs / 1000 / 60 : undefined; + await this.createAlarm(taskName, { + periodInMinutes: intervalInMinutes, + delayInMinutes: initialDelayInMinutes ?? intervalInMinutes, + }); + } + + async clearScheduledTask(taskIdentifier: TaskIdentifier): Promise { + void super.clearScheduledTask(taskIdentifier); + + const { taskName } = taskIdentifier; + if (!taskName) { + return; + } + + const wasCleared = await BrowserApi.clearAlarm(taskName); + if (wasCleared) { + await this.deleteActiveAlarm(taskName); + this.recoveredAlarms.delete(taskName); + } + } + + async clearAllScheduledTasks(): Promise { + await BrowserApi.clearAllAlarms(); + await this.updateActiveAlarms([]); + this.onAlarmHandlers = {}; + this.recoveredAlarms.clear(); + } + + private async createAlarm( + name: ScheduledTaskName, + createInfo: chrome.alarms.AlarmCreateInfo, + ): Promise { + const existingAlarm = await BrowserApi.getAlarm(name); + if (existingAlarm) { + this.logService.debug(`Alarm ${name} already exists. Skipping creation.`); + return; + } + + await BrowserApi.createAlarm(name, createInfo); + await this.setActiveAlarm({ name, startTime: Date.now(), createInfo }); + } + + private registerAlarmHandler(name: ScheduledTaskName, handler: CallableFunction): void { + if (this.onAlarmHandlers[name]) { + this.logService.warning(`Alarm handler for ${name} already exists. Overwriting.`); + } + + this.onAlarmHandlers[name] = () => handler(); + } + + private async verifyAlarmsState(): Promise { + const currentTime = Date.now(); + const activeAlarms = await firstValueFrom(this.activeAlarms$); + + for (const alarm of activeAlarms) { + const { name, startTime, createInfo } = alarm; + const existingAlarm = await BrowserApi.getAlarm(name); + if (existingAlarm) { + continue; + } + + if ( + (createInfo.when && createInfo.when < currentTime) || + (!createInfo.periodInMinutes && + createInfo.delayInMinutes && + startTime + createInfo.delayInMinutes * 60 * 1000 < currentTime) + ) { + this.recoveredAlarms.add(name); + continue; + } + + void this.createAlarm(name, createInfo); + } + + // 10 seconds after verifying the alarm state, we should treat any newly created alarms as non-recovered alarms. + setTimeout(() => this.recoveredAlarms.clear(), 10 * 1000); + } + + private async setActiveAlarm(alarm: ActiveAlarm): Promise { + const activeAlarms = await firstValueFrom(this.activeAlarms$); + activeAlarms.push(alarm); + await this.updateActiveAlarms(activeAlarms); + } + + private async deleteActiveAlarm(name: ScheduledTaskName): Promise { + const activeAlarms = await firstValueFrom(this.activeAlarms$); + const filteredAlarms = activeAlarms.filter((alarm) => alarm.name !== name); + await this.updateActiveAlarms(filteredAlarms); + delete this.onAlarmHandlers[name]; + } + + private async updateActiveAlarms(alarms: ActiveAlarm[]): Promise { + await this.activeAlarmsState.update(() => alarms); + } + + private async triggerRecoveredAlarm(name: ScheduledTaskName): Promise { + this.recoveredAlarms.delete(name); + await this.triggerAlarm(name); + } + + private setupOnAlarmListener(): void { + BrowserApi.addListener(chrome.alarms.onAlarm, this.handleOnAlarm); + } + + private handleOnAlarm = async (alarm: chrome.alarms.Alarm): Promise => { + await this.triggerAlarm(alarm.name as ScheduledTaskName); + }; + + private async triggerAlarm(name: ScheduledTaskName): Promise { + const handler = this.onAlarmHandlers[name]; + if (handler) { + handler(); + } + + const alarm = await BrowserApi.getAlarm(name); + if (alarm?.periodInMinutes) { + return; + } + + await this.deleteActiveAlarm(name); + } +} diff --git a/libs/common/src/platform/abstractions/task-scheduler.service.ts b/libs/common/src/platform/abstractions/task-scheduler.service.ts new file mode 100644 index 0000000000..94312a21be --- /dev/null +++ b/libs/common/src/platform/abstractions/task-scheduler.service.ts @@ -0,0 +1,22 @@ +import { ScheduledTaskName } from "../enums/scheduled-task-name.enum"; + +export type TaskIdentifier = { + taskName?: ScheduledTaskName; + timeoutId?: number | NodeJS.Timeout; + intervalId?: number | NodeJS.Timeout; +}; + +export interface TaskSchedulerService { + setTimeout( + callback: () => void, + delayInMs: number, + taskName?: ScheduledTaskName, + ): Promise; + setInterval( + callback: () => void, + intervalInMs: number, + taskName?: ScheduledTaskName, + initialDelayInMs?: number, + ): Promise; + clearScheduledTask(taskIdentifier: TaskIdentifier): Promise; +} diff --git a/libs/common/src/platform/enums/scheduled-task-name.enum.ts b/libs/common/src/platform/enums/scheduled-task-name.enum.ts new file mode 100644 index 0000000000..3860ee21e6 --- /dev/null +++ b/libs/common/src/platform/enums/scheduled-task-name.enum.ts @@ -0,0 +1,11 @@ +export const ScheduledTaskNames = { + clearClipboardTimeout: "clearClipboardTimeout", + systemClearClipboardTimeout: "systemClearClipboardTimeout", + scheduleNextSyncTimeout: "scheduleNextSyncTimeout", + loginStrategySessionTimeout: "loginStrategySessionTimeout", + notificationsReconnectTimeout: "notificationsReconnectTimeout", + fido2ClientAbortTimeout: "fido2ClientAbortTimeout", + eventUploadsInterval: "eventUploadsInterval", +} as const; + +export type ScheduledTaskName = (typeof ScheduledTaskNames)[keyof typeof ScheduledTaskNames]; diff --git a/libs/common/src/platform/services/task-scheduler.service.ts b/libs/common/src/platform/services/task-scheduler.service.ts new file mode 100644 index 0000000000..b4a947d73b --- /dev/null +++ b/libs/common/src/platform/services/task-scheduler.service.ts @@ -0,0 +1,35 @@ +import { + TaskIdentifier, + TaskSchedulerService as TaskSchedulerServiceInterface, +} from "../abstractions/task-scheduler.service"; +import { ScheduledTaskName } from "../enums/scheduled-task-name.enum"; + +export class TaskSchedulerService implements TaskSchedulerServiceInterface { + async setTimeout( + callback: () => void, + delayInMs: number, + _taskName?: ScheduledTaskName, + ): Promise { + return setTimeout(() => callback(), delayInMs); + } + + async setInterval( + callback: () => void, + intervalInMs: number, + _taskName?: ScheduledTaskName, + _initialDelayInMs?: number, + ): Promise { + return setInterval(() => callback(), intervalInMs); + } + + async clearScheduledTask(taskIdentifier: TaskIdentifier): Promise { + if (taskIdentifier.timeoutId) { + clearTimeout(taskIdentifier.timeoutId); + return; + } + + if (taskIdentifier.intervalId) { + clearInterval(taskIdentifier.intervalId); + } + } +} diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 814bf0280f..872d29f872 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -87,6 +87,7 @@ export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory"); export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" }); export const TRANSLATION_DISK = new StateDefinition("translation", "disk"); +export const SCHEDULED_TASKS_DISK = new StateDefinition("scheduledTasks", "disk"); // Secrets Manager