[PM-6426] Create TaskSchedulerService and update long lived timeouts in the extension to leverage the new service (#8566)
* [PM-6426] Create TaskSchedulerService and update usage of long lived timeouts * [PM-6426] Implementing nextSync timeout using TaskScheduler * [PM-6426] Implementing systemClearClipboard using TaskScheduler * [PM-6426] Fixing race condition with setting/unsetting active alarms * [PM-6426] Implementing clear clipboard call on generatePasswordToClipboard with the TaskSchedulerService * [PM-6426] Implementing abortTimeout for Fido2ClientService using TaskSchedulerService * [PM-6426] Implementing reconnect timer timeout for NotificationService using the TaskSchedulerService * [PM-6426] Implementing reconnect timer timeout for NotificationService using the TaskSchedulerService * [PM-6426] Implementing sessionTimeout for LoginStrategyService using TaskSchedulerService * [PM-6426] Implementing eventUploadInterval using TaskScheduler * [PM-6426] Adding jest tests for the base TaskSchedulerService class * [PM-6426] Updating jest tests for GeneratePasswordToClipboardCommand * [PM-6426] Setting up the full sync process as an interval rather than a timeout * [PM-6426] Renaming the scheduleNextSync alarm name * [PM-6426] Fixing dependency references in services.module.ts * [PM-6426] Adding jest tests for added BrowserApi methods * [PM-6426] Refactoring small detail for how we identify the clear clipboard timeout in SystemService * [PM-6426] Ensuring that we await clearing an established scheduled task for the notification service * [PM-6426] Changing the name of the state definition for the TaskScheduler * [PM-6426] Implementing jest tests for the BrowserTaskSchedulerService * [PM-6426] Implementing jest tests for the BrowserTaskSchedulerService * [PM-6426] Adding jest tests for the base TaskSchedulerService class * [PM-6426] Finalizing jest tests for BrowserTaskScheduler class * [PM-6426] Finalizing documentation on BrowserTaskSchedulerService * [PM-6426] Fixing jest test for LoginStrategyService * [PM-6426] Implementing compatibility for the browser.alarms api * [PM-6426] Fixing how we check for the browser alarms api * [PM-6426] Adding jest tests to the BrowserApi implementation * [PM-6426] Aligning the implementation with our code guidelines for Angular components * [PM-6426] Fixing jest tests and lint errors * [PM-6426] Moving alarms api calls out of BrowserApi and structuring them within the BrowserTaskSchedulerService * [PM-6426] Reworking implementation to register handlers separately from the call to those handlers * [PM-6426] Adjusting how we register the fullSync scheduled task * [PM-6426] Implementing approach for incorporating the user UUID when setting task handlers * [PM-6426] Attempting to re-work implementation to facilitate userId-spcific alarms * [PM-6426] Refactoring smaller details of the implementation * [PM-6426] Working through the details of the implementation and setting up final refinments * [PM-6426] Fixing some issues surrounding duplicate alarms triggering * [PM-6426] Adjusting name for generate password to clipboard command task name * [PM-6426] Fixing generate password to clipboard command jest tests * [PM-6426] Working through jest tests and implementing a method to guard against setting a task without having a registered callback * [PM-6426] Working through jest tests and implementing a method to guard against setting a task without having a registered callback * [PM-6426] Implementing methodology for having a fallback to setTimeout if the browser context is lost in some manner * [PM-6426] Working through jest tests * [PM-6426] Working through jest tests * [PM-6426] Working through jest tests * [PM-6426] Working through jest tests * [PM-6426] Finalizing stepped setInterval implementation * [PM-6426] Implementing Jest tests for DefaultTaskSchedulerService * [PM-6426] Adjusting jest tests * [PM-6426] Adjusting jest tests * [PM-6426] Adjusting jest tests * [PM-6426] Fixing issues identified in code review * [PM-6426] Fixing issues identified in code review * [PM-6426] Removing user-based alarms and fixing an issue found with setting steppedd alarm interavals * [PM-6426] Removing user-based alarms and fixing an issue found with setting steppedd alarm interavals * [PM-6426] Fixing issue with typing information on a test * [PM-6426] Using the getUpperBoundDelayInMinutes method to handle setting stepped alarms and setTimeout fallbacks * [PM-6426] Removing the potential for the TaskScheduler to be optional * [PM-6426] Reworking implementation to leverage subscription based deregistration of alarms * [PM-6426] Fixing jest tests * [PM-6426] Implementing foreground and background task scheduler services to avoid duplication of task scheudlers and to have the background setup as a fallback to the poopup tasks * [PM-6426] Implementing foreground and background task scheduler services to avoid duplication of task scheudlers and to have the background setup as a fallback to the poopup tasks * [PM-6426] Merging main into branch * [PM-6426] Fixing issues with the CLI Service Container implementation * [PM-6426] Reworking swallowed promises to contain a catch statement allow us to debug potential issues with registrations of alarms * [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService * [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService * [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService * [PM-6426] Adding jest tests to the ForegroundTaskSchedulerService and the BackgroundTaskSchedulerService * [PM-6426] Adjusting implementation based on code review feedback * [PM-6426] Reworking file structure * [PM-6426] Reworking file structure * [PM-6426] Adding comments to provide clarity on how the login strategy cache experiation state is used * [PM-6426] Catching and logging erorrs that appear from methods that return a promise within VaultTimeoutService
This commit is contained in:
parent
5fcf4bbd10
commit
974162b1a4
|
@ -1,11 +1,9 @@
|
|||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
export const clearClipboardAlarmName = "clearClipboard";
|
||||
|
||||
export class ClearClipboard {
|
||||
/**
|
||||
We currently rely on an active tab with an injected content script (`../content/misc-utils.ts`) to clear the clipboard via `window.navigator.clipboard.writeText(text)`
|
||||
|
||||
|
||||
With https://bugs.chromium.org/p/chromium/issues/detail?id=1160302 it was said that service workers,
|
||||
would have access to the clipboard api and then we could migrate to a simpler solution
|
||||
*/
|
||||
|
|
|
@ -1,30 +1,45 @@
|
|||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, Subscription } from "rxjs";
|
||||
|
||||
import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { setAlarmTime } from "../../platform/alarms/alarm-state";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { BrowserTaskSchedulerService } from "../../platform/services/abstractions/browser-task-scheduler.service";
|
||||
|
||||
import { clearClipboardAlarmName } from "./clear-clipboard";
|
||||
import { ClearClipboard } from "./clear-clipboard";
|
||||
import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command";
|
||||
|
||||
jest.mock("../../platform/alarms/alarm-state", () => {
|
||||
jest.mock("rxjs", () => {
|
||||
const actual = jest.requireActual("rxjs");
|
||||
return {
|
||||
setAlarmTime: jest.fn(),
|
||||
...actual,
|
||||
firstValueFrom: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const setAlarmTimeMock = setAlarmTime as jest.Mock;
|
||||
|
||||
describe("GeneratePasswordToClipboardCommand", () => {
|
||||
let passwordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||
let autofillSettingsService: MockProxy<AutofillSettingsService>;
|
||||
let browserTaskSchedulerService: MockProxy<BrowserTaskSchedulerService>;
|
||||
|
||||
let sut: GeneratePasswordToClipboardCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
||||
autofillSettingsService = mock<AutofillSettingsService>();
|
||||
browserTaskSchedulerService = mock<BrowserTaskSchedulerService>({
|
||||
setTimeout: jest.fn((taskName, timeoutInMs) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (taskName === ScheduledTaskNames.generatePasswordClearClipboardTimeout) {
|
||||
void ClearClipboard.run();
|
||||
}
|
||||
}, timeoutInMs);
|
||||
|
||||
return new Subscription(() => clearTimeout(timeoutHandle));
|
||||
}),
|
||||
});
|
||||
|
||||
passwordGenerationService.getOptions.mockResolvedValue([{ length: 8 }, {} as any]);
|
||||
|
||||
|
@ -35,6 +50,7 @@ describe("GeneratePasswordToClipboardCommand", () => {
|
|||
sut = new GeneratePasswordToClipboardCommand(
|
||||
passwordGenerationService,
|
||||
autofillSettingsService,
|
||||
browserTaskSchedulerService,
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -44,20 +60,24 @@ describe("GeneratePasswordToClipboardCommand", () => {
|
|||
|
||||
describe("generatePasswordToClipboard", () => {
|
||||
it("has clear clipboard value", async () => {
|
||||
jest.spyOn(sut as any, "getClearClipboard").mockImplementation(() => 5 * 60); // 5 minutes
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(ClearClipboard, "run");
|
||||
(firstValueFrom as jest.Mock).mockResolvedValue(2 * 60); // 2 minutes
|
||||
|
||||
await sut.generatePasswordToClipboard({ id: 1 } as any);
|
||||
jest.advanceTimersByTime(2 * 60 * 1000);
|
||||
|
||||
expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledWith(1, {
|
||||
command: "copyText",
|
||||
text: "PASSWORD",
|
||||
});
|
||||
|
||||
expect(setAlarmTimeMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(setAlarmTimeMock).toHaveBeenCalledWith(clearClipboardAlarmName, expect.any(Number));
|
||||
expect(browserTaskSchedulerService.setTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(browserTaskSchedulerService.setTimeout).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.generatePasswordClearClipboardTimeout,
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(ClearClipboard.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not have clear clipboard value", async () => {
|
||||
|
@ -71,8 +91,7 @@ describe("GeneratePasswordToClipboardCommand", () => {
|
|||
command: "copyText",
|
||||
text: "PASSWORD",
|
||||
});
|
||||
|
||||
expect(setAlarmTimeMock).not.toHaveBeenCalled();
|
||||
expect(browserTaskSchedulerService.setTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,18 +1,25 @@
|
|||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, Subscription } from "rxjs";
|
||||
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { setAlarmTime } from "../../platform/alarms/alarm-state";
|
||||
|
||||
import { clearClipboardAlarmName } from "./clear-clipboard";
|
||||
import { ClearClipboard } from "./clear-clipboard";
|
||||
import { copyToClipboard } from "./copy-to-clipboard-command";
|
||||
|
||||
export class GeneratePasswordToClipboardCommand {
|
||||
private clearClipboardSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||
) {}
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.generatePasswordClearClipboardTimeout,
|
||||
() => ClearClipboard.run(),
|
||||
);
|
||||
}
|
||||
|
||||
async getClearClipboard() {
|
||||
return await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$);
|
||||
|
@ -22,14 +29,18 @@ export class GeneratePasswordToClipboardCommand {
|
|||
const [options] = await this.passwordGenerationService.getOptions();
|
||||
const password = await this.passwordGenerationService.generatePassword(options);
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
copyToClipboard(tab, password);
|
||||
await copyToClipboard(tab, password);
|
||||
|
||||
const clearClipboard = await this.getClearClipboard();
|
||||
|
||||
if (clearClipboard != null) {
|
||||
await setAlarmTime(clearClipboardAlarmName, clearClipboard * 1000);
|
||||
const clearClipboardDelayInSeconds = await this.getClearClipboard();
|
||||
if (!clearClipboardDelayInSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutInMs = clearClipboardDelayInSeconds * 1000;
|
||||
this.clearClipboardSubscription?.unsubscribe();
|
||||
this.clearClipboardSubscription = this.taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.generatePasswordClearClipboardTimeout,
|
||||
timeoutInMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { mock } from "jest-mock-extended";
|
||||
|
||||
function triggerTestFailure() {
|
||||
export function triggerTestFailure() {
|
||||
expect(true).toBe("Test has failed.");
|
||||
}
|
||||
|
||||
const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout;
|
||||
function flushPromises() {
|
||||
export function flushPromises() {
|
||||
return new Promise(function (resolve) {
|
||||
scheduler(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function postWindowMessage(data: any, origin = "https://localhost/", source = window) {
|
||||
export function postWindowMessage(data: any, origin = "https://localhost/", source = window) {
|
||||
globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source }));
|
||||
}
|
||||
|
||||
function sendMockExtensionMessage(
|
||||
export function sendMockExtensionMessage(
|
||||
message: any,
|
||||
sender?: chrome.runtime.MessageSender,
|
||||
sendResponse?: CallableFunction,
|
||||
|
@ -32,7 +32,7 @@ function sendMockExtensionMessage(
|
|||
);
|
||||
}
|
||||
|
||||
function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) {
|
||||
export function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) {
|
||||
(chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||
(call) => {
|
||||
const callback = call[0];
|
||||
|
@ -41,21 +41,21 @@ function triggerRuntimeOnConnectEvent(port: chrome.runtime.Port) {
|
|||
);
|
||||
}
|
||||
|
||||
function sendPortMessage(port: chrome.runtime.Port, message: any) {
|
||||
export function sendPortMessage(port: chrome.runtime.Port, message: any) {
|
||||
(port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||
const callback = call[0];
|
||||
callback(message || {}, port);
|
||||
});
|
||||
}
|
||||
|
||||
function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) {
|
||||
export function triggerPortOnDisconnectEvent(port: chrome.runtime.Port) {
|
||||
(port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||
const callback = call[0];
|
||||
callback(port);
|
||||
});
|
||||
}
|
||||
|
||||
function triggerWindowOnFocusedChangedEvent(windowId: number) {
|
||||
export function triggerWindowOnFocusedChangedEvent(windowId: number) {
|
||||
(chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||
(call) => {
|
||||
const callback = call[0];
|
||||
|
@ -64,7 +64,7 @@ function triggerWindowOnFocusedChangedEvent(windowId: number) {
|
|||
);
|
||||
}
|
||||
|
||||
function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) {
|
||||
export function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) {
|
||||
(chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||
(call) => {
|
||||
const callback = call[0];
|
||||
|
@ -73,14 +73,14 @@ function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) {
|
|||
);
|
||||
}
|
||||
|
||||
function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) {
|
||||
export function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: number) {
|
||||
(chrome.tabs.onReplaced.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||
const callback = call[0];
|
||||
callback(addedTabId, removedTabId);
|
||||
});
|
||||
}
|
||||
|
||||
function triggerTabOnUpdatedEvent(
|
||||
export function triggerTabOnUpdatedEvent(
|
||||
tabId: number,
|
||||
changeInfo: chrome.tabs.TabChangeInfo,
|
||||
tab: chrome.tabs.Tab,
|
||||
|
@ -91,14 +91,21 @@ function triggerTabOnUpdatedEvent(
|
|||
});
|
||||
}
|
||||
|
||||
function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) {
|
||||
export function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) {
|
||||
(chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||
const callback = call[0];
|
||||
callback(tabId, removeInfo);
|
||||
});
|
||||
}
|
||||
|
||||
function mockQuerySelectorAllDefinedCall() {
|
||||
export function triggerOnAlarmEvent(alarm: chrome.alarms.Alarm) {
|
||||
(chrome.alarms.onAlarm.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||
const callback = call[0];
|
||||
callback(alarm);
|
||||
});
|
||||
}
|
||||
|
||||
export function mockQuerySelectorAllDefinedCall() {
|
||||
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
||||
document.querySelectorAll = function (selector: string) {
|
||||
return originalDocumentQuerySelectorAll.call(
|
||||
|
@ -125,19 +132,3 @@ function mockQuerySelectorAllDefinedCall() {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
triggerTestFailure,
|
||||
flushPromises,
|
||||
postWindowMessage,
|
||||
sendMockExtensionMessage,
|
||||
triggerRuntimeOnConnectEvent,
|
||||
sendPortMessage,
|
||||
triggerPortOnDisconnectEvent,
|
||||
triggerWindowOnFocusedChangedEvent,
|
||||
triggerTabOnActivatedEvent,
|
||||
triggerTabOnReplacedEvent,
|
||||
triggerTabOnUpdatedEvent,
|
||||
triggerTabOnRemovedEvent,
|
||||
mockQuerySelectorAllDefinedCall,
|
||||
};
|
||||
|
|
|
@ -105,6 +105,7 @@ import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
|
|||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
|
@ -216,6 +217,7 @@ import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender
|
|||
/* eslint-enable no-restricted-imports */
|
||||
import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document";
|
||||
import { DefaultOffscreenDocumentService } from "../platform/offscreen-document/offscreen-document.service";
|
||||
import { BrowserTaskSchedulerService } from "../platform/services/abstractions/browser-task-scheduler.service";
|
||||
import { BrowserCryptoService } from "../platform/services/browser-crypto.service";
|
||||
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||
import BrowserLocalStorageService from "../platform/services/browser-local-storage.service";
|
||||
|
@ -225,6 +227,8 @@ import I18nService from "../platform/services/i18n.service";
|
|||
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
||||
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
|
||||
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
|
||||
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
|
||||
import { ForegroundTaskSchedulerService } from "../platform/services/task-scheduler/foreground-task-scheduler.service";
|
||||
import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
|
||||
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
|
||||
import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service";
|
||||
|
@ -322,6 +326,7 @@ export default class MainBackground {
|
|||
activeUserStateProvider: ActiveUserStateProvider;
|
||||
derivedStateProvider: DerivedStateProvider;
|
||||
stateProvider: StateProvider;
|
||||
taskSchedulerService: BrowserTaskSchedulerService;
|
||||
fido2Background: Fido2BackgroundAbstraction;
|
||||
individualVaultExportService: IndividualVaultExportServiceAbstraction;
|
||||
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
|
||||
|
@ -511,6 +516,14 @@ export default class MainBackground {
|
|||
this.globalStateProvider,
|
||||
this.derivedStateProvider,
|
||||
);
|
||||
|
||||
this.taskSchedulerService = this.popupOnlyContext
|
||||
? new ForegroundTaskSchedulerService(this.logService, this.stateProvider)
|
||||
: new BackgroundTaskSchedulerService(this.logService, this.stateProvider);
|
||||
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.scheduleNextSyncInterval, () =>
|
||||
this.fullSync(),
|
||||
);
|
||||
|
||||
this.environmentService = new BrowserEnvironmentService(
|
||||
this.logService,
|
||||
this.stateProvider,
|
||||
|
@ -779,6 +792,8 @@ export default class MainBackground {
|
|||
this.authService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.stateEventRunnerService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
lockedCallback,
|
||||
logoutCallback,
|
||||
);
|
||||
|
@ -858,6 +873,7 @@ export default class MainBackground {
|
|||
this.stateProvider,
|
||||
this.logService,
|
||||
this.authService,
|
||||
this.taskSchedulerService,
|
||||
);
|
||||
this.eventCollectionService = new EventCollectionService(
|
||||
this.cipherService,
|
||||
|
@ -935,6 +951,7 @@ export default class MainBackground {
|
|||
this.stateService,
|
||||
this.authService,
|
||||
this.messagingService,
|
||||
this.taskSchedulerService,
|
||||
);
|
||||
|
||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||
|
@ -950,16 +967,17 @@ export default class MainBackground {
|
|||
this.authService,
|
||||
this.vaultSettingsService,
|
||||
this.domainSettingsService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
const systemUtilsServiceReloadCallback = () => {
|
||||
const systemUtilsServiceReloadCallback = async () => {
|
||||
const forceWindowReload =
|
||||
this.platformUtilsService.isSafari() ||
|
||||
this.platformUtilsService.isFirefox() ||
|
||||
this.platformUtilsService.isOpera();
|
||||
await this.taskSchedulerService.clearAllScheduledTasks();
|
||||
BrowserApi.reloadExtension(forceWindowReload ? self : null);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
this.systemService = new SystemService(
|
||||
|
@ -971,6 +989,7 @@ export default class MainBackground {
|
|||
this.vaultTimeoutSettingsService,
|
||||
this.biometricStateService,
|
||||
this.accountService,
|
||||
this.taskSchedulerService,
|
||||
);
|
||||
|
||||
// Other fields
|
||||
|
@ -1184,7 +1203,12 @@ export default class MainBackground {
|
|||
setTimeout(async () => {
|
||||
await this.refreshBadge();
|
||||
await this.fullSync(true);
|
||||
await this.taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.scheduleNextSyncInterval,
|
||||
5 * 60 * 1000, // check every 5 minutes
|
||||
);
|
||||
setTimeout(() => this.notificationsService.init(), 2500);
|
||||
await this.taskSchedulerService.verifyAlarmsState();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
|
@ -1453,17 +1477,6 @@ export default class MainBackground {
|
|||
|
||||
if (override || lastSyncAgo >= syncInternal) {
|
||||
await this.syncService.fullSync(override);
|
||||
this.scheduleNextSync();
|
||||
} else {
|
||||
this.scheduleNextSync();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextSync() {
|
||||
if (this.syncTimeout) {
|
||||
clearTimeout(this.syncTimeout);
|
||||
}
|
||||
|
||||
this.syncTimeout = setTimeout(async () => await this.fullSync(), 5 * 60 * 1000); // check every 5 minutes
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
"clipboardRead",
|
||||
"clipboardWrite",
|
||||
"idle",
|
||||
"alarms",
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"webNavigation"
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
"clipboardRead",
|
||||
"clipboardWrite",
|
||||
"idle",
|
||||
"alarms",
|
||||
"scripting",
|
||||
"offscreen",
|
||||
"webRequest",
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
import { clearClipboardAlarmName } from "../../autofill/clipboard";
|
||||
import { BrowserApi } from "../browser/browser-api";
|
||||
|
||||
export const alarmKeys = [clearClipboardAlarmName] as const;
|
||||
export type AlarmKeys = (typeof alarmKeys)[number];
|
||||
|
||||
type AlarmState = { [T in AlarmKeys]: number | undefined };
|
||||
|
||||
const alarmState: AlarmState = {
|
||||
clearClipboard: null,
|
||||
//TODO once implemented vaultTimeout: null;
|
||||
//TODO once implemented checkNotifications: null;
|
||||
//TODO once implemented (if necessary) processReload: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the set alarm time (planned execution) for a give an commandName {@link AlarmState}
|
||||
* @param commandName A command that has been previously registered with {@link AlarmState}
|
||||
* @returns {Promise<number>} null or Unix epoch timestamp when the alarm action is supposed to execute
|
||||
* @example
|
||||
* // getAlarmTime(clearClipboard)
|
||||
*/
|
||||
export async function getAlarmTime(commandName: AlarmKeys): Promise<number> {
|
||||
let alarmTime: number;
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
const fromSessionStore = await chrome.storage.session.get(commandName);
|
||||
alarmTime = fromSessionStore[commandName];
|
||||
} else {
|
||||
alarmTime = alarmState[commandName];
|
||||
}
|
||||
|
||||
return alarmTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an action that should execute after the given time has passed
|
||||
* @param commandName A command that has been previously registered with {@link AlarmState}
|
||||
* @param delay_ms The number of ms from now in which the command should execute from
|
||||
* @example
|
||||
* // setAlarmTime(clearClipboard, 5000) register the clearClipboard action which will execute when at least 5 seconds from now have passed
|
||||
*/
|
||||
export async function setAlarmTime(commandName: AlarmKeys, delay_ms: number): Promise<void> {
|
||||
if (!delay_ms || delay_ms === 0) {
|
||||
await this.clearAlarmTime(commandName);
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Date.now() + delay_ms;
|
||||
await setAlarmTimeInternal(commandName, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the time currently set for a given command
|
||||
* @param commandName A command that has been previously registered with {@link AlarmState}
|
||||
*/
|
||||
export async function clearAlarmTime(commandName: AlarmKeys): Promise<void> {
|
||||
await setAlarmTimeInternal(commandName, null);
|
||||
}
|
||||
|
||||
async function setAlarmTimeInternal(commandName: AlarmKeys, time: number): Promise<void> {
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
await chrome.storage.session.set({ [commandName]: time });
|
||||
} else {
|
||||
alarmState[commandName] = time;
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import { ClearClipboard, clearClipboardAlarmName } from "../../autofill/clipboard";
|
||||
|
||||
import { alarmKeys, clearAlarmTime, getAlarmTime } from "./alarm-state";
|
||||
|
||||
export const onAlarmListener = async (alarm: chrome.alarms.Alarm) => {
|
||||
alarmKeys.forEach(async (key) => {
|
||||
const executionTime = await getAlarmTime(key);
|
||||
if (!executionTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDate = Date.now();
|
||||
if (executionTime > currentDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
await clearAlarmTime(key);
|
||||
|
||||
switch (key) {
|
||||
case clearClipboardAlarmName:
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ClearClipboard.run();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
const NUMBER_OF_ALARMS = 6;
|
||||
|
||||
export function registerAlarms() {
|
||||
alarmsToBeCreated(NUMBER_OF_ALARMS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates staggered alarms that periodically (1min) raise OnAlarm events. The staggering is calculated based on the number of alarms passed in.
|
||||
* @param numberOfAlarms Number of named alarms, that shall be registered
|
||||
* @example
|
||||
* // alarmsToBeCreated(2) results in 2 alarms separated by 30 seconds
|
||||
* @example
|
||||
* // alarmsToBeCreated(4) results in 4 alarms separated by 15 seconds
|
||||
* @example
|
||||
* // alarmsToBeCreated(6) results in 6 alarms separated by 10 seconds
|
||||
* @example
|
||||
* // alarmsToBeCreated(60) results in 60 alarms separated by 1 second
|
||||
*/
|
||||
function alarmsToBeCreated(numberOfAlarms: number): void {
|
||||
const oneMinuteInMs = 60 * 1000;
|
||||
const offset = oneMinuteInMs / numberOfAlarms;
|
||||
|
||||
let calculatedWhen: number = Date.now() + offset;
|
||||
|
||||
for (let index = 0; index < numberOfAlarms; index++) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
chrome.alarms.create(`bw_alarm${index}`, { periodInMinutes: 1, when: calculatedWhen });
|
||||
calculatedWhen += offset;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { Observable } from "rxjs";
|
||||
|
||||
import { TaskSchedulerService, ScheduledTaskName } from "@bitwarden/common/platform/scheduling";
|
||||
|
||||
export const BrowserTaskSchedulerPortName = "browser-task-scheduler-port";
|
||||
|
||||
export const BrowserTaskSchedulerPortActions = {
|
||||
setTimeout: "setTimeout",
|
||||
setInterval: "setInterval",
|
||||
clearAlarm: "clearAlarm",
|
||||
} as const;
|
||||
export type BrowserTaskSchedulerPortAction = keyof typeof BrowserTaskSchedulerPortActions;
|
||||
|
||||
export type BrowserTaskSchedulerPortMessage = {
|
||||
action: BrowserTaskSchedulerPortAction;
|
||||
taskName: ScheduledTaskName;
|
||||
alarmName?: string;
|
||||
delayInMs?: number;
|
||||
intervalInMs?: number;
|
||||
};
|
||||
|
||||
export type ActiveAlarm = {
|
||||
alarmName: string;
|
||||
startTime: number;
|
||||
createInfo: chrome.alarms.AlarmCreateInfo;
|
||||
};
|
||||
|
||||
export abstract class BrowserTaskSchedulerService extends TaskSchedulerService {
|
||||
activeAlarms$: Observable<ActiveAlarm[]>;
|
||||
abstract clearAllScheduledTasks(): Promise<void>;
|
||||
abstract verifyAlarmsState(): Promise<void>;
|
||||
abstract clearScheduledAlarm(alarmName: string): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { GlobalState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
|
||||
import {
|
||||
flushPromises,
|
||||
sendPortMessage,
|
||||
triggerPortOnDisconnectEvent,
|
||||
triggerRuntimeOnConnectEvent,
|
||||
} from "../../../autofill/spec/testing-utils";
|
||||
import {
|
||||
BrowserTaskSchedulerPortActions,
|
||||
BrowserTaskSchedulerPortName,
|
||||
} from "../abstractions/browser-task-scheduler.service";
|
||||
|
||||
import { BackgroundTaskSchedulerService } from "./background-task-scheduler.service";
|
||||
|
||||
describe("BackgroundTaskSchedulerService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let globalStateMock: MockProxy<GlobalState<any>>;
|
||||
let portMock: chrome.runtime.Port;
|
||||
let backgroundTaskSchedulerService: BackgroundTaskSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock<LogService>();
|
||||
globalStateMock = mock<GlobalState<any>>({
|
||||
state$: mock<Observable<any>>(),
|
||||
update: jest.fn((callback) => callback([], {} as any)),
|
||||
});
|
||||
stateProvider = mock<StateProvider>({
|
||||
getGlobal: jest.fn(() => globalStateMock),
|
||||
});
|
||||
portMock = createPortSpyMock(BrowserTaskSchedulerPortName);
|
||||
backgroundTaskSchedulerService = new BackgroundTaskSchedulerService(logService, stateProvider);
|
||||
jest.spyOn(globalThis, "setTimeout");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ports on connect", () => {
|
||||
it("ignores port connections that do not have the correct task scheduler port name", () => {
|
||||
const portMockWithDifferentName = createPortSpyMock("different-name");
|
||||
triggerRuntimeOnConnectEvent(portMockWithDifferentName);
|
||||
|
||||
expect(portMockWithDifferentName.onMessage.addListener).not.toHaveBeenCalled();
|
||||
expect(portMockWithDifferentName.onDisconnect.addListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets up onMessage and onDisconnect listeners for connected ports", () => {
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
|
||||
expect(portMock.onMessage.addListener).toHaveBeenCalled();
|
||||
expect(portMock.onDisconnect.addListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ports on disconnect", () => {
|
||||
it("removes the port from the set of connected ports", () => {
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
expect(backgroundTaskSchedulerService["ports"].size).toBe(1);
|
||||
|
||||
triggerPortOnDisconnectEvent(portMock);
|
||||
expect(backgroundTaskSchedulerService["ports"].size).toBe(0);
|
||||
expect(portMock.onMessage.removeListener).toHaveBeenCalled();
|
||||
expect(portMock.onDisconnect.removeListener).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("port message handlers", () => {
|
||||
beforeEach(() => {
|
||||
triggerRuntimeOnConnectEvent(portMock);
|
||||
backgroundTaskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
jest.fn(),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets a setTimeout backup alarm", async () => {
|
||||
sendPortMessage(portMock, {
|
||||
action: BrowserTaskSchedulerPortActions.setTimeout,
|
||||
taskName: ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs: 1000,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.setTimeout).toHaveBeenCalled();
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ delayInMinutes: 0.5 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets a setInterval backup alarm", async () => {
|
||||
sendPortMessage(portMock, {
|
||||
action: BrowserTaskSchedulerPortActions.setInterval,
|
||||
taskName: ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs: 600000,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ delayInMinutes: 10, periodInMinutes: 10 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a scheduled alarm", async () => {
|
||||
sendPortMessage(portMock, {
|
||||
action: BrowserTaskSchedulerPortActions.clearAlarm,
|
||||
alarmName: ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { BrowserApi } from "../../browser/browser-api";
|
||||
import {
|
||||
BrowserTaskSchedulerPortActions,
|
||||
BrowserTaskSchedulerPortMessage,
|
||||
BrowserTaskSchedulerPortName,
|
||||
} from "../abstractions/browser-task-scheduler.service";
|
||||
|
||||
import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service";
|
||||
|
||||
export class BackgroundTaskSchedulerService extends BrowserTaskSchedulerServiceImplementation {
|
||||
private ports: Set<chrome.runtime.Port> = new Set();
|
||||
|
||||
constructor(logService: LogService, stateProvider: StateProvider) {
|
||||
super(logService, stateProvider);
|
||||
|
||||
BrowserApi.addListener(chrome.runtime.onConnect, this.handlePortOnConnect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a port connection made from the foreground task scheduler.
|
||||
*
|
||||
* @param port - The port that was connected.
|
||||
*/
|
||||
private handlePortOnConnect = (port: chrome.runtime.Port) => {
|
||||
if (port.name !== BrowserTaskSchedulerPortName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ports.add(port);
|
||||
port.onMessage.addListener(this.handlePortMessage);
|
||||
port.onDisconnect.addListener(this.handlePortOnDisconnect);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a port disconnection.
|
||||
*
|
||||
* @param port - The port that was disconnected.
|
||||
*/
|
||||
private handlePortOnDisconnect = (port: chrome.runtime.Port) => {
|
||||
port.onMessage.removeListener(this.handlePortMessage);
|
||||
port.onDisconnect.removeListener(this.handlePortOnDisconnect);
|
||||
this.ports.delete(port);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a message from a port.
|
||||
*
|
||||
* @param message - The message that was received.
|
||||
* @param port - The port that sent the message.
|
||||
*/
|
||||
private handlePortMessage = (
|
||||
message: BrowserTaskSchedulerPortMessage,
|
||||
port: chrome.runtime.Port,
|
||||
) => {
|
||||
const isTaskSchedulerPort = port.name === BrowserTaskSchedulerPortName;
|
||||
const { action, taskName, alarmName, delayInMs, intervalInMs } = message;
|
||||
|
||||
if (isTaskSchedulerPort && action === BrowserTaskSchedulerPortActions.setTimeout) {
|
||||
super.setTimeout(taskName, delayInMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTaskSchedulerPort && action === BrowserTaskSchedulerPortActions.setInterval) {
|
||||
super.setInterval(taskName, intervalInMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTaskSchedulerPort && action === BrowserTaskSchedulerPortActions.clearAlarm) {
|
||||
super.clearScheduledAlarm(alarmName).catch((error) => this.logService.error(error));
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,463 @@
|
|||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { GlobalState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { flushPromises, triggerOnAlarmEvent } from "../../../autofill/spec/testing-utils";
|
||||
import {
|
||||
ActiveAlarm,
|
||||
BrowserTaskSchedulerService,
|
||||
} from "../abstractions/browser-task-scheduler.service";
|
||||
|
||||
import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service";
|
||||
|
||||
jest.mock("rxjs", () => {
|
||||
const actualModule = jest.requireActual("rxjs");
|
||||
return {
|
||||
...actualModule,
|
||||
firstValueFrom: jest.fn((state$: BehaviorSubject<any>) => state$.value),
|
||||
};
|
||||
});
|
||||
|
||||
function setupGlobalBrowserMock(overrides: Partial<chrome.alarms.Alarm> = {}) {
|
||||
globalThis.browser.alarms = {
|
||||
create: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
clearAll: jest.fn(),
|
||||
onAlarm: {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
hasListener: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowserTaskSchedulerService", () => {
|
||||
const callback = jest.fn();
|
||||
const delayInMinutes = 2;
|
||||
let activeAlarmsMock$: BehaviorSubject<ActiveAlarm[]>;
|
||||
let logService: MockProxy<ConsoleLogService>;
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let globalStateMock: MockProxy<GlobalState<any>>;
|
||||
let browserTaskSchedulerService: BrowserTaskSchedulerService;
|
||||
let activeAlarms: ActiveAlarm[] = [];
|
||||
const eventUploadsIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 };
|
||||
const scheduleNextSyncIntervalCreateInfo = { periodInMinutes: 5, delayInMinutes: 5 };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
activeAlarms = [
|
||||
mock<ActiveAlarm>({
|
||||
alarmName: ScheduledTaskNames.eventUploadsInterval,
|
||||
createInfo: eventUploadsIntervalCreateInfo,
|
||||
}),
|
||||
mock<ActiveAlarm>({
|
||||
alarmName: ScheduledTaskNames.scheduleNextSyncInterval,
|
||||
createInfo: scheduleNextSyncIntervalCreateInfo,
|
||||
}),
|
||||
mock<ActiveAlarm>({
|
||||
alarmName: ScheduledTaskNames.fido2ClientAbortTimeout,
|
||||
startTime: Date.now() - 60001,
|
||||
createInfo: { delayInMinutes: 1, periodInMinutes: undefined },
|
||||
}),
|
||||
];
|
||||
activeAlarmsMock$ = new BehaviorSubject(activeAlarms);
|
||||
logService = mock<ConsoleLogService>();
|
||||
globalStateMock = mock<GlobalState<any>>({
|
||||
state$: mock<Observable<any>>(),
|
||||
update: jest.fn((callback) => callback([], {} as any)),
|
||||
});
|
||||
stateProvider = mock<StateProvider>({
|
||||
getGlobal: jest.fn(() => globalStateMock),
|
||||
});
|
||||
browserTaskSchedulerService = new BrowserTaskSchedulerServiceImplementation(
|
||||
logService,
|
||||
stateProvider,
|
||||
);
|
||||
browserTaskSchedulerService.activeAlarms$ = activeAlarmsMock$;
|
||||
browserTaskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
callback,
|
||||
);
|
||||
// @ts-expect-error mocking global browser object
|
||||
// eslint-disable-next-line no-global-assign
|
||||
globalThis.browser = {};
|
||||
chrome.alarms.get = jest.fn().mockImplementation((_name, callback) => callback(undefined));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
|
||||
// eslint-disable-next-line no-global-assign
|
||||
globalThis.browser = undefined;
|
||||
});
|
||||
|
||||
describe("setTimeout", () => {
|
||||
it("triggers an error when setting a timeout for a task that is not registered", async () => {
|
||||
expect(() =>
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||
1000,
|
||||
),
|
||||
).toThrow(
|
||||
`Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a timeout alarm", async () => {
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMinutes * 60 * 1000,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ delayInMinutes },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips creating a duplicate timeout alarm", async () => {
|
||||
const mockAlarm = mock<chrome.alarms.Alarm>();
|
||||
chrome.alarms.get = jest.fn().mockImplementation((_name, callback) => callback(mockAlarm));
|
||||
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMinutes * 60 * 1000,
|
||||
);
|
||||
|
||||
expect(chrome.alarms.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("when the task is scheduled to be triggered in less than the minimum possible delay", () => {
|
||||
const delayInMs = 25000;
|
||||
|
||||
it("sets a timeout using the global setTimeout API", async () => {
|
||||
jest.spyOn(globalThis, "setTimeout");
|
||||
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), delayInMs);
|
||||
});
|
||||
|
||||
it("sets a fallback alarm", async () => {
|
||||
const delayInMs = 15000;
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ delayInMinutes: 0.5 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the fallback for a minimum of 1 minute if the environment not for Chrome", async () => {
|
||||
setupGlobalBrowserMock();
|
||||
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(browser.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ delayInMinutes: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the fallback alarm when the setTimeout is triggered", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs,
|
||||
);
|
||||
jest.advanceTimersByTime(delayInMs);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a subscription that can be used to clear the timeout", () => {
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
const timeoutSubscription = browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
10000,
|
||||
);
|
||||
|
||||
timeoutSubscription.unsubscribe();
|
||||
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(globalThis.clearTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears alarms in non-chrome environments", () => {
|
||||
setupGlobalBrowserMock();
|
||||
|
||||
const timeoutSubscription = browserTaskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
10000,
|
||||
);
|
||||
timeoutSubscription.unsubscribe();
|
||||
|
||||
expect(browser.alarms.clear).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setInterval", () => {
|
||||
it("triggers an error when setting an interval for a task that is not registered", async () => {
|
||||
expect(() => {
|
||||
browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||
1000,
|
||||
);
|
||||
}).toThrow(
|
||||
`Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("setting an interval that is less than 1 minute", () => {
|
||||
const intervalInMs = 10000;
|
||||
|
||||
it("sets up stepped alarms that trigger behavior after the first minute of setInterval execution", async () => {
|
||||
browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__0`,
|
||||
{ periodInMinutes: 0.6666666666666666, delayInMinutes: 0.5 },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__1`,
|
||||
{ periodInMinutes: 0.6666666666666666, delayInMinutes: 0.6666666666666666 },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__2`,
|
||||
{ periodInMinutes: 0.6666666666666666, delayInMinutes: 0.8333333333333333 },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__3`,
|
||||
{ periodInMinutes: 0.6666666666666666, delayInMinutes: 1 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets an interval using the global setInterval API", async () => {
|
||||
jest.spyOn(globalThis, "setInterval");
|
||||
|
||||
browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), intervalInMs);
|
||||
});
|
||||
|
||||
it("clears the global setInterval instance once the interval has elapsed the minimum required delay for an alarm", async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearInterval");
|
||||
|
||||
browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(50000);
|
||||
|
||||
expect(globalThis.clearInterval).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an interval alarm", async () => {
|
||||
const periodInMinutes = 2;
|
||||
const initialDelayInMs = 1000;
|
||||
|
||||
browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
periodInMinutes * 60 * 1000,
|
||||
initialDelayInMs,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ periodInMinutes, delayInMinutes: 0.5 },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the alarm's delay in minutes to the interval in minutes if the delay is not specified", async () => {
|
||||
const periodInMinutes = 2;
|
||||
browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
periodInMinutes * 60 * 1000,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
{ periodInMinutes, delayInMinutes: periodInMinutes },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a subscription that can be used to clear an interval alarm", () => {
|
||||
jest.spyOn(globalThis, "clearInterval");
|
||||
|
||||
const intervalSubscription = browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
600000,
|
||||
);
|
||||
|
||||
intervalSubscription.unsubscribe();
|
||||
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(globalThis.clearInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a subscription that can be used to clear all stepped interval alarms", () => {
|
||||
jest.spyOn(globalThis, "clearInterval");
|
||||
|
||||
const intervalSubscription = browserTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
10000,
|
||||
);
|
||||
|
||||
intervalSubscription.unsubscribe();
|
||||
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__0`,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__1`,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__2`,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(chrome.alarms.clear).toHaveBeenCalledWith(
|
||||
`${ScheduledTaskNames.loginStrategySessionTimeout}__3`,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(globalThis.clearInterval).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyAlarmsState", () => {
|
||||
it("skips recovering a scheduled task if an existing alarm for the task is present", async () => {
|
||||
chrome.alarms.get = jest
|
||||
.fn()
|
||||
.mockImplementation((_name, callback) => callback(mock<chrome.alarms.Alarm>()));
|
||||
|
||||
await browserTaskSchedulerService.verifyAlarmsState();
|
||||
|
||||
expect(chrome.alarms.create).not.toHaveBeenCalled();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("extension alarm is not set", () => {
|
||||
it("triggers the task when the task should have triggered", async () => {
|
||||
const fido2Callback = jest.fn();
|
||||
browserTaskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.fido2ClientAbortTimeout,
|
||||
fido2Callback,
|
||||
);
|
||||
|
||||
await browserTaskSchedulerService.verifyAlarmsState();
|
||||
|
||||
expect(fido2Callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules an alarm for the task when it has not yet triggered ", async () => {
|
||||
const syncCallback = jest.fn();
|
||||
browserTaskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.scheduleNextSyncInterval,
|
||||
syncCallback,
|
||||
);
|
||||
|
||||
await browserTaskSchedulerService.verifyAlarmsState();
|
||||
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
ScheduledTaskNames.scheduleNextSyncInterval,
|
||||
scheduleNextSyncIntervalCreateInfo,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggering a task", () => {
|
||||
it("triggers a task when an onAlarm event is triggered", () => {
|
||||
const alarm = mock<chrome.alarms.Alarm>({
|
||||
name: ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
});
|
||||
|
||||
triggerOnAlarmEvent(alarm);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAllScheduledTasks", () => {
|
||||
it("clears all scheduled tasks and extension alarms", async () => {
|
||||
// @ts-expect-error mocking global state update method
|
||||
globalStateMock.update = jest.fn((callback) => {
|
||||
const stateValue = callback([], {} as any);
|
||||
activeAlarmsMock$.next(stateValue);
|
||||
return stateValue;
|
||||
});
|
||||
|
||||
await browserTaskSchedulerService.clearAllScheduledTasks();
|
||||
|
||||
expect(chrome.alarms.clearAll).toHaveBeenCalled();
|
||||
expect(activeAlarmsMock$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears all extension alarms within a non Chrome environment", async () => {
|
||||
setupGlobalBrowserMock();
|
||||
|
||||
await browserTaskSchedulerService.clearAllScheduledTasks();
|
||||
|
||||
expect(browser.alarms.clearAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,427 @@
|
|||
import { firstValueFrom, map, Observable, Subscription } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
DefaultTaskSchedulerService,
|
||||
ScheduledTaskName,
|
||||
} from "@bitwarden/common/platform/scheduling";
|
||||
import {
|
||||
TASK_SCHEDULER_DISK,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
|
||||
import { BrowserApi } from "../../browser/browser-api";
|
||||
import {
|
||||
ActiveAlarm,
|
||||
BrowserTaskSchedulerService,
|
||||
} from "../abstractions/browser-task-scheduler.service";
|
||||
|
||||
const ACTIVE_ALARMS = new KeyDefinition(TASK_SCHEDULER_DISK, "activeAlarms", {
|
||||
deserializer: (value: ActiveAlarm[]) => value ?? [],
|
||||
});
|
||||
|
||||
export class BrowserTaskSchedulerServiceImplementation
|
||||
extends DefaultTaskSchedulerService
|
||||
implements BrowserTaskSchedulerService
|
||||
{
|
||||
private activeAlarmsState: GlobalState<ActiveAlarm[]>;
|
||||
readonly activeAlarms$: Observable<ActiveAlarm[]>;
|
||||
|
||||
constructor(
|
||||
logService: LogService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
super(logService);
|
||||
|
||||
this.activeAlarmsState = this.stateProvider.getGlobal(ACTIVE_ALARMS);
|
||||
this.activeAlarms$ = this.activeAlarmsState.state$.pipe(
|
||||
map((activeAlarms) => activeAlarms ?? []),
|
||||
);
|
||||
|
||||
this.setupOnAlarmListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a timeout to execute a callback after a delay. If the delay is less
|
||||
* than 1 minute, it will use the global setTimeout. Otherwise, it will
|
||||
* create a browser extension alarm to handle the delay.
|
||||
*
|
||||
* @param taskName - The name of the task, used in defining the alarm.
|
||||
* @param delayInMs - The delay in milliseconds.
|
||||
*/
|
||||
setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription {
|
||||
let timeoutHandle: number | NodeJS.Timeout;
|
||||
this.validateRegisteredTask(taskName);
|
||||
|
||||
const delayInMinutes = delayInMs / 1000 / 60;
|
||||
this.scheduleAlarm(taskName, {
|
||||
delayInMinutes: this.getUpperBoundDelayInMinutes(delayInMinutes),
|
||||
}).catch((error) => this.logService.error("Failed to schedule alarm", error));
|
||||
|
||||
// If the delay is less than a minute, we want to attempt to trigger the task through a setTimeout.
|
||||
// The alarm previously scheduled will be used as a backup in case the setTimeout fails.
|
||||
if (delayInMinutes < this.getUpperBoundDelayInMinutes(delayInMinutes)) {
|
||||
timeoutHandle = globalThis.setTimeout(async () => {
|
||||
await this.clearScheduledAlarm(taskName);
|
||||
await this.triggerTask(taskName);
|
||||
}, delayInMs);
|
||||
}
|
||||
|
||||
return new Subscription(() => {
|
||||
if (timeoutHandle) {
|
||||
globalThis.clearTimeout(timeoutHandle);
|
||||
}
|
||||
this.clearScheduledAlarm(taskName).catch((error) =>
|
||||
this.logService.error("Failed to clear alarm", error),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an interval to execute a callback at each interval. If the interval is
|
||||
* less than 1 minute, it will use the global setInterval. Otherwise, it will
|
||||
* create a browser extension alarm to handle the interval.
|
||||
*
|
||||
* @param taskName - The name of the task, used in defining the alarm.
|
||||
* @param intervalInMs - The interval in milliseconds.
|
||||
* @param initialDelayInMs - The initial delay in milliseconds.
|
||||
*/
|
||||
setInterval(
|
||||
taskName: ScheduledTaskName,
|
||||
intervalInMs: number,
|
||||
initialDelayInMs?: number,
|
||||
): Subscription {
|
||||
this.validateRegisteredTask(taskName);
|
||||
|
||||
const intervalInMinutes = intervalInMs / 1000 / 60;
|
||||
const initialDelayInMinutes = initialDelayInMs
|
||||
? initialDelayInMs / 1000 / 60
|
||||
: intervalInMinutes;
|
||||
|
||||
if (intervalInMinutes < this.getUpperBoundDelayInMinutes(intervalInMinutes)) {
|
||||
return this.setupSteppedIntervalAlarms(taskName, intervalInMs);
|
||||
}
|
||||
|
||||
this.scheduleAlarm(taskName, {
|
||||
periodInMinutes: this.getUpperBoundDelayInMinutes(intervalInMinutes),
|
||||
delayInMinutes: this.getUpperBoundDelayInMinutes(initialDelayInMinutes),
|
||||
}).catch((error) => this.logService.error("Failed to schedule alarm", error));
|
||||
|
||||
return new Subscription(() =>
|
||||
this.clearScheduledAlarm(taskName).catch((error) =>
|
||||
this.logService.error("Failed to clear alarm", error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in cases where the interval is less than 1 minute. This method will set up a setInterval
|
||||
* to initialize expected recurring behavior, then create a series of alarms to handle the
|
||||
* expected scheduled task through the alarms api. This is necessary because the alarms
|
||||
* api does not support intervals less than 1 minute.
|
||||
*
|
||||
* @param taskName - The name of the task
|
||||
* @param intervalInMs - The interval in milliseconds.
|
||||
*/
|
||||
private setupSteppedIntervalAlarms(
|
||||
taskName: ScheduledTaskName,
|
||||
intervalInMs: number,
|
||||
): Subscription {
|
||||
const alarmMinDelayInMinutes = this.getAlarmMinDelayInMinutes();
|
||||
const intervalInMinutes = intervalInMs / 1000 / 60;
|
||||
const numberOfAlarmsToCreate = Math.ceil(Math.ceil(1 / intervalInMinutes) / 2) + 1;
|
||||
const steppedAlarmPeriodInMinutes = alarmMinDelayInMinutes + intervalInMinutes;
|
||||
const steppedAlarmNames: string[] = [];
|
||||
for (let alarmIndex = 0; alarmIndex < numberOfAlarmsToCreate; alarmIndex++) {
|
||||
const steppedAlarmName = `${taskName}__${alarmIndex}`;
|
||||
steppedAlarmNames.push(steppedAlarmName);
|
||||
|
||||
const delayInMinutes = this.getUpperBoundDelayInMinutes(
|
||||
alarmMinDelayInMinutes + intervalInMinutes * alarmIndex,
|
||||
);
|
||||
|
||||
this.clearScheduledAlarm(steppedAlarmName)
|
||||
.then(() =>
|
||||
this.scheduleAlarm(steppedAlarmName, {
|
||||
periodInMinutes: steppedAlarmPeriodInMinutes,
|
||||
delayInMinutes,
|
||||
}).catch((error) => this.logService.error("Failed to schedule alarm", error)),
|
||||
)
|
||||
.catch((error) => this.logService.error("Failed to clear alarm", error));
|
||||
}
|
||||
|
||||
let elapsedMs = 0;
|
||||
const intervalHandle: number | NodeJS.Timeout = globalThis.setInterval(async () => {
|
||||
elapsedMs += intervalInMs;
|
||||
const elapsedMinutes = elapsedMs / 1000 / 60;
|
||||
|
||||
if (elapsedMinutes >= alarmMinDelayInMinutes) {
|
||||
globalThis.clearInterval(intervalHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.triggerTask(taskName, intervalInMinutes);
|
||||
}, intervalInMs);
|
||||
|
||||
return new Subscription(() => {
|
||||
if (intervalHandle) {
|
||||
globalThis.clearInterval(intervalHandle);
|
||||
}
|
||||
steppedAlarmNames.forEach((alarmName) =>
|
||||
this.clearScheduledAlarm(alarmName).catch((error) =>
|
||||
this.logService.error("Failed to clear alarm", error),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all scheduled tasks by clearing all browser extension
|
||||
* alarms and resetting the active alarms state.
|
||||
*/
|
||||
async clearAllScheduledTasks(): Promise<void> {
|
||||
await this.clearAllAlarms();
|
||||
await this.updateActiveAlarms([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the state of the active alarms by checking if
|
||||
* any alarms have been missed or need to be created.
|
||||
*/
|
||||
async verifyAlarmsState(): Promise<void> {
|
||||
const currentTime = Date.now();
|
||||
const activeAlarms = await this.getActiveAlarms();
|
||||
|
||||
for (const alarm of activeAlarms) {
|
||||
const { alarmName, startTime, createInfo } = alarm;
|
||||
const existingAlarm = await this.getAlarm(alarmName);
|
||||
if (existingAlarm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shouldAlarmHaveBeenTriggered = createInfo.when && createInfo.when < currentTime;
|
||||
const hasSetTimeoutAlarmExceededDelay =
|
||||
!createInfo.periodInMinutes &&
|
||||
createInfo.delayInMinutes &&
|
||||
startTime + createInfo.delayInMinutes * 60 * 1000 < currentTime;
|
||||
if (shouldAlarmHaveBeenTriggered || hasSetTimeoutAlarmExceededDelay) {
|
||||
await this.triggerTask(alarmName);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.scheduleAlarm(alarmName, createInfo).catch((error) =>
|
||||
this.logService.error("Failed to schedule alarm", error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a browser extension alarm with the given name and create info.
|
||||
*
|
||||
* @param alarmName - The name of the alarm.
|
||||
* @param createInfo - The alarm create info.
|
||||
*/
|
||||
private async scheduleAlarm(
|
||||
alarmName: string,
|
||||
createInfo: chrome.alarms.AlarmCreateInfo,
|
||||
): Promise<void> {
|
||||
const existingAlarm = await this.getAlarm(alarmName);
|
||||
if (existingAlarm) {
|
||||
this.logService.debug(`Alarm ${alarmName} already exists. Skipping creation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.createAlarm(alarmName, createInfo);
|
||||
await this.setActiveAlarm(alarmName, createInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active alarms from state.
|
||||
*/
|
||||
private async getActiveAlarms(): Promise<ActiveAlarm[]> {
|
||||
return await firstValueFrom(this.activeAlarms$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an active alarm in state.
|
||||
*
|
||||
* @param alarmName - The name of the active alarm to set.
|
||||
* @param createInfo - The creation info of the active alarm.
|
||||
*/
|
||||
private async setActiveAlarm(
|
||||
alarmName: string,
|
||||
createInfo: chrome.alarms.AlarmCreateInfo,
|
||||
): Promise<void> {
|
||||
const activeAlarms = await this.getActiveAlarms();
|
||||
const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName);
|
||||
filteredAlarms.push({
|
||||
alarmName,
|
||||
startTime: Date.now(),
|
||||
createInfo,
|
||||
});
|
||||
await this.updateActiveAlarms(filteredAlarms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an active alarm from state.
|
||||
*
|
||||
* @param alarmName - The name of the active alarm to delete.
|
||||
*/
|
||||
private async deleteActiveAlarm(alarmName: string): Promise<void> {
|
||||
const activeAlarms = await this.getActiveAlarms();
|
||||
const filteredAlarms = activeAlarms.filter((alarm) => alarm.alarmName !== alarmName);
|
||||
await this.updateActiveAlarms(filteredAlarms || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a scheduled alarm by its name and deletes it from the active alarms state.
|
||||
*
|
||||
* @param alarmName - The name of the alarm to clear.
|
||||
*/
|
||||
async clearScheduledAlarm(alarmName: string): Promise<void> {
|
||||
const wasCleared = await this.clearAlarm(alarmName);
|
||||
if (wasCleared) {
|
||||
await this.deleteActiveAlarm(alarmName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the active alarms state with the given alarms.
|
||||
*
|
||||
* @param alarms - The alarms to update the state with.
|
||||
*/
|
||||
private async updateActiveAlarms(alarms: ActiveAlarm[]): Promise<void> {
|
||||
await this.activeAlarmsState.update(() => alarms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the on alarm listener to handle alarms.
|
||||
*/
|
||||
private setupOnAlarmListener(): void {
|
||||
BrowserApi.addListener(chrome.alarms.onAlarm, this.handleOnAlarm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles on alarm events, triggering the alarm if a handler exists.
|
||||
*
|
||||
* @param alarm - The alarm to handle.
|
||||
*/
|
||||
private handleOnAlarm = async (alarm: chrome.alarms.Alarm): Promise<void> => {
|
||||
const { name, periodInMinutes } = alarm;
|
||||
await this.triggerTask(name, periodInMinutes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers an alarm by calling its handler and
|
||||
* deleting it if it is a one-time alarm.
|
||||
*
|
||||
* @param alarmName - The name of the alarm to trigger.
|
||||
* @param periodInMinutes - The period in minutes of an interval alarm.
|
||||
*/
|
||||
protected async triggerTask(alarmName: string, periodInMinutes?: number): Promise<void> {
|
||||
const taskName = this.getTaskFromAlarmName(alarmName);
|
||||
const handler = this.taskHandlers.get(taskName);
|
||||
if (!periodInMinutes) {
|
||||
await this.deleteActiveAlarm(alarmName);
|
||||
}
|
||||
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and returns the task name from an alarm name.
|
||||
*
|
||||
* @param alarmName - The alarm name to parse.
|
||||
*/
|
||||
protected getTaskFromAlarmName(alarmName: string): ScheduledTaskName {
|
||||
return alarmName.split("__")[0] as ScheduledTaskName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a new alarm with the given name and create info. Returns a promise
|
||||
* that indicates when the alarm has been cleared successfully.
|
||||
*
|
||||
* @param alarmName - The name of the alarm to create.
|
||||
*/
|
||||
private async clearAlarm(alarmName: string): Promise<boolean> {
|
||||
if (this.isNonChromeEnvironment()) {
|
||||
return browser.alarms.clear(alarmName);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => chrome.alarms.clear(alarmName, resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all alarms that have been set by the extension. Returns a promise
|
||||
* that indicates when all alarms have been cleared successfully.
|
||||
*/
|
||||
private clearAllAlarms(): Promise<boolean> {
|
||||
if (this.isNonChromeEnvironment()) {
|
||||
return browser.alarms.clearAll();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => chrome.alarms.clearAll(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new alarm with the given name and create info.
|
||||
*
|
||||
* @param alarmName - The name of the alarm to create.
|
||||
* @param createInfo - The creation info for the alarm.
|
||||
*/
|
||||
private async createAlarm(
|
||||
alarmName: string,
|
||||
createInfo: chrome.alarms.AlarmCreateInfo,
|
||||
): Promise<void> {
|
||||
if (this.isNonChromeEnvironment()) {
|
||||
return browser.alarms.create(alarmName, createInfo);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => chrome.alarms.create(alarmName, createInfo, resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the alarm with the given name.
|
||||
*
|
||||
* @param alarmName - The name of the alarm to get.
|
||||
*/
|
||||
private getAlarm(alarmName: string): Promise<chrome.alarms.Alarm> {
|
||||
if (this.isNonChromeEnvironment()) {
|
||||
return browser.alarms.get(alarmName);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => chrome.alarms.get(alarmName, resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the environment is a non-Chrome environment. This is used to determine
|
||||
* if the browser alarms API should be used in place of the chrome alarms API. This
|
||||
* is necessary because the `chrome` polyfill that Mozilla implements does not allow
|
||||
* passing the callback parameter in the same way most `chrome.alarm` api calls allow.
|
||||
*/
|
||||
private isNonChromeEnvironment(): boolean {
|
||||
return typeof browser !== "undefined" && !!browser.alarms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the minimum delay in minutes for an alarm. This is used to ensure that the
|
||||
* delay is at least 1 minute in non-Chrome environments. In Chrome environments, the
|
||||
* delay can be as low as 0.5 minutes.
|
||||
*/
|
||||
private getAlarmMinDelayInMinutes(): number {
|
||||
return this.isNonChromeEnvironment() ? 1 : 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the upper bound delay in minutes for a given delay in minutes.
|
||||
*
|
||||
* @param delayInMinutes - The delay in minutes.
|
||||
*/
|
||||
private getUpperBoundDelayInMinutes(delayInMinutes: number): number {
|
||||
return Math.max(this.getAlarmMinDelayInMinutes(), delayInMinutes);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { GlobalState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { createPortSpyMock } from "../../../autofill/spec/autofill-mocks";
|
||||
import { flushPromises } from "../../../autofill/spec/testing-utils";
|
||||
import {
|
||||
BrowserTaskSchedulerPortActions,
|
||||
BrowserTaskSchedulerPortName,
|
||||
} from "../abstractions/browser-task-scheduler.service";
|
||||
|
||||
import { ForegroundTaskSchedulerService } from "./foreground-task-scheduler.service";
|
||||
|
||||
describe("ForegroundTaskSchedulerService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateProvider: MockProxy<StateProvider>;
|
||||
let globalStateMock: MockProxy<GlobalState<any>>;
|
||||
let portMock: chrome.runtime.Port;
|
||||
let foregroundTaskSchedulerService: ForegroundTaskSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock<LogService>();
|
||||
globalStateMock = mock<GlobalState<any>>({
|
||||
state$: mock<Observable<any>>(),
|
||||
update: jest.fn((callback) => callback([], {} as any)),
|
||||
});
|
||||
stateProvider = mock<StateProvider>({
|
||||
getGlobal: jest.fn(() => globalStateMock),
|
||||
});
|
||||
portMock = createPortSpyMock(BrowserTaskSchedulerPortName);
|
||||
foregroundTaskSchedulerService = new ForegroundTaskSchedulerService(logService, stateProvider);
|
||||
foregroundTaskSchedulerService["port"] = portMock;
|
||||
foregroundTaskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
jest.fn(),
|
||||
);
|
||||
jest.spyOn(globalThis, "setTimeout");
|
||||
jest.spyOn(globalThis, "setInterval");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sets a timeout for a task and sends a message to the background to set up a backup timeout alarm", async () => {
|
||||
foregroundTaskSchedulerService.setTimeout(ScheduledTaskNames.loginStrategySessionTimeout, 1000);
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000);
|
||||
expect(chrome.alarms.create).toHaveBeenCalledWith(
|
||||
"loginStrategySessionTimeout",
|
||||
{ delayInMinutes: 0.5 },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(portMock.postMessage).toHaveBeenCalledWith({
|
||||
action: BrowserTaskSchedulerPortActions.setTimeout,
|
||||
taskName: ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets an interval for a task and sends a message to the background to set up a backup interval alarm", async () => {
|
||||
foregroundTaskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
1000,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 1000);
|
||||
expect(portMock.postMessage).toHaveBeenCalledWith({
|
||||
action: BrowserTaskSchedulerPortActions.setInterval,
|
||||
taskName: ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs: 1000,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
import { Subscription } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ScheduledTaskName } from "@bitwarden/common/platform/scheduling";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import {
|
||||
BrowserTaskSchedulerPortActions,
|
||||
BrowserTaskSchedulerPortMessage,
|
||||
BrowserTaskSchedulerPortName,
|
||||
} from "../abstractions/browser-task-scheduler.service";
|
||||
|
||||
import { BrowserTaskSchedulerServiceImplementation } from "./browser-task-scheduler.service";
|
||||
|
||||
export class ForegroundTaskSchedulerService extends BrowserTaskSchedulerServiceImplementation {
|
||||
private port: chrome.runtime.Port;
|
||||
|
||||
constructor(logService: LogService, stateProvider: StateProvider) {
|
||||
super(logService, stateProvider);
|
||||
|
||||
this.port = chrome.runtime.connect({ name: BrowserTaskSchedulerPortName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a port message to the background to set up a fallback timeout. Also sets a timeout locally.
|
||||
* This is done to ensure that the timeout triggers even if the popup is closed.
|
||||
*
|
||||
* @param taskName - The name of the task.
|
||||
* @param delayInMs - The delay in milliseconds.
|
||||
*/
|
||||
setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription {
|
||||
this.sendPortMessage({
|
||||
action: BrowserTaskSchedulerPortActions.setTimeout,
|
||||
taskName,
|
||||
delayInMs,
|
||||
});
|
||||
|
||||
return super.setTimeout(taskName, delayInMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a port message to the background to set up a fallback interval. Also sets an interval locally.
|
||||
* This is done to ensure that the interval triggers even if the popup is closed.
|
||||
*
|
||||
* @param taskName - The name of the task.
|
||||
* @param intervalInMs - The interval in milliseconds.
|
||||
* @param initialDelayInMs - The initial delay in milliseconds.
|
||||
*/
|
||||
setInterval(
|
||||
taskName: ScheduledTaskName,
|
||||
intervalInMs: number,
|
||||
initialDelayInMs?: number,
|
||||
): Subscription {
|
||||
this.sendPortMessage({
|
||||
action: BrowserTaskSchedulerPortActions.setInterval,
|
||||
taskName,
|
||||
intervalInMs,
|
||||
});
|
||||
|
||||
return super.setInterval(taskName, intervalInMs, initialDelayInMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the background task scheduler.
|
||||
*
|
||||
* @param message - The message to send.
|
||||
*/
|
||||
private sendPortMessage(message: BrowserTaskSchedulerPortMessage) {
|
||||
this.port.postMessage(message);
|
||||
}
|
||||
}
|
|
@ -61,6 +61,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
|
|||
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
|
@ -102,6 +103,7 @@ import BrowserLocalStorageService from "../../platform/services/browser-local-st
|
|||
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
|
||||
import I18nService from "../../platform/services/i18n.service";
|
||||
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service";
|
||||
import { ForegroundTaskSchedulerService } from "../../platform/services/task-scheduler/foreground-task-scheduler.service";
|
||||
import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
|
||||
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
|
||||
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
|
||||
|
@ -516,6 +518,15 @@ const safeProviders: SafeProvider[] = [
|
|||
useClass: Fido2UserVerificationService,
|
||||
deps: [PasswordRepromptService, UserVerificationService, DialogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TaskSchedulerService,
|
||||
useExisting: ForegroundTaskSchedulerService,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ForegroundTaskSchedulerService,
|
||||
useFactory: getBgService<ForegroundTaskSchedulerService>("taskSchedulerService"),
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
|
|
@ -4,16 +4,13 @@ import { SafariApp } from "../../browser/safariApp";
|
|||
|
||||
export default class VaultTimeoutService extends BaseVaultTimeoutService {
|
||||
startCheck() {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.checkVaultTimeout();
|
||||
if (this.platformUtilsService.isSafari()) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.checkSafari();
|
||||
} else {
|
||||
setInterval(() => this.checkVaultTimeout(), 10 * 1000); // check every 10 seconds
|
||||
this.checkVaultTimeout().catch((error) => this.logService.error(error));
|
||||
this.checkSafari().catch((error) => this.logService.error(error));
|
||||
return;
|
||||
}
|
||||
|
||||
super.startCheck();
|
||||
}
|
||||
|
||||
// This is a work-around to safari adding an arbitrary delay to setTimeout and
|
||||
|
|
|
@ -143,6 +143,18 @@ const webNavigation = {
|
|||
},
|
||||
};
|
||||
|
||||
const alarms = {
|
||||
clear: jest.fn().mockImplementation((_name, callback) => callback(true)),
|
||||
clearAll: jest.fn().mockImplementation((callback) => callback(true)),
|
||||
create: jest.fn().mockImplementation((_name, _createInfo, callback) => callback()),
|
||||
get: jest.fn().mockImplementation((_name, callback) => callback(null)),
|
||||
getAll: jest.fn().mockImplementation((callback) => callback([])),
|
||||
onAlarm: {
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// set chrome
|
||||
global.chrome = {
|
||||
i18n,
|
||||
|
@ -158,4 +170,5 @@ global.chrome = {
|
|||
offscreen,
|
||||
permissions,
|
||||
webNavigation,
|
||||
alarms,
|
||||
} as any;
|
||||
|
|
|
@ -65,6 +65,10 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
|
|||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import {
|
||||
TaskSchedulerService,
|
||||
DefaultTaskSchedulerService,
|
||||
} from "@bitwarden/common/platform/scheduling";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
|
@ -239,6 +243,7 @@ export class ServiceContainer {
|
|||
providerApiService: ProviderApiServiceAbstraction;
|
||||
userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||
kdfConfigService: KdfConfigServiceAbstraction;
|
||||
taskSchedulerService: TaskSchedulerService;
|
||||
|
||||
constructor() {
|
||||
let p = null;
|
||||
|
@ -543,6 +548,7 @@ export class ServiceContainer {
|
|||
this.stateProvider,
|
||||
);
|
||||
|
||||
this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService);
|
||||
this.loginStrategyService = new LoginStrategyService(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
|
@ -568,6 +574,7 @@ export class ServiceContainer {
|
|||
this.billingAccountProfileStateService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.kdfConfigService,
|
||||
this.taskSchedulerService,
|
||||
);
|
||||
|
||||
this.authService = new AuthService(
|
||||
|
@ -642,6 +649,8 @@ export class ServiceContainer {
|
|||
this.authService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.stateEventRunnerService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
lockedCallback,
|
||||
null,
|
||||
);
|
||||
|
@ -724,6 +733,7 @@ export class ServiceContainer {
|
|||
this.stateProvider,
|
||||
this.logService,
|
||||
this.authService,
|
||||
this.taskSchedulerService,
|
||||
);
|
||||
|
||||
this.eventCollectionService = new EventCollectionService(
|
||||
|
|
|
@ -45,6 +45,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
|
|||
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
||||
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
@ -177,6 +178,7 @@ const safeProviders: SafeProvider[] = [
|
|||
VaultTimeoutSettingsService,
|
||||
BiometricStateService,
|
||||
AccountServiceAbstraction,
|
||||
TaskSchedulerService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
|
|
@ -157,6 +157,10 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter
|
|||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import {
|
||||
TaskSchedulerService,
|
||||
DefaultTaskSchedulerService,
|
||||
} from "@bitwarden/common/platform/scheduling";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
|
@ -409,6 +413,7 @@ const safeProviders: SafeProvider[] = [
|
|||
BillingAccountProfileStateService,
|
||||
VaultTimeoutSettingsServiceAbstraction,
|
||||
KdfConfigServiceAbstraction,
|
||||
TaskSchedulerService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
@ -714,6 +719,8 @@ const safeProviders: SafeProvider[] = [
|
|||
AuthServiceAbstraction,
|
||||
VaultTimeoutSettingsServiceAbstraction,
|
||||
StateEventRunnerService,
|
||||
TaskSchedulerService,
|
||||
LogService,
|
||||
LOCKED_CALLBACK,
|
||||
LOGOUT_CALLBACK,
|
||||
],
|
||||
|
@ -812,6 +819,7 @@ const safeProviders: SafeProvider[] = [
|
|||
StateServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
TaskSchedulerService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
@ -827,7 +835,13 @@ const safeProviders: SafeProvider[] = [
|
|||
safeProvider({
|
||||
provide: EventUploadServiceAbstraction,
|
||||
useClass: EventUploadService,
|
||||
deps: [ApiServiceAbstraction, StateProvider, LogService, AuthServiceAbstraction],
|
||||
deps: [
|
||||
ApiServiceAbstraction,
|
||||
StateProvider,
|
||||
LogService,
|
||||
AuthServiceAbstraction,
|
||||
TaskSchedulerService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EventCollectionServiceAbstraction,
|
||||
|
@ -1215,6 +1229,11 @@ const safeProviders: SafeProvider[] = [
|
|||
new SubjectMessageSender(subject),
|
||||
deps: [INTRAPROCESS_MESSAGING_SUBJECT],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TaskSchedulerService,
|
||||
useClass: DefaultTaskSchedulerService,
|
||||
deps: [LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ProviderApiServiceAbstraction,
|
||||
useClass: ProviderApiService,
|
||||
|
|
|
@ -27,6 +27,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
|||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeGlobalState,
|
||||
|
@ -72,6 +73,7 @@ describe("LoginStrategyService", () => {
|
|||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
|
||||
let stateProvider: FakeGlobalStateProvider;
|
||||
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
|
||||
|
@ -103,6 +105,7 @@ describe("LoginStrategyService", () => {
|
|||
stateProvider = new FakeGlobalStateProvider();
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
|
||||
sut = new LoginStrategyService(
|
||||
accountService,
|
||||
|
@ -129,6 +132,7 @@ describe("LoginStrategyService", () => {
|
|||
billingAccountProfileStateService,
|
||||
vaultTimeoutSettingsService,
|
||||
kdfConfigService,
|
||||
taskSchedulerService,
|
||||
);
|
||||
|
||||
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
Subscription,
|
||||
} from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
|
@ -37,6 +38,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
|||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KdfType } from "@bitwarden/common/platform/enums/kdf-type.enum";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
|
@ -69,7 +71,7 @@ import {
|
|||
const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private sessionTimeout: unknown;
|
||||
private sessionTimeoutSubscription: Subscription;
|
||||
private currentAuthnTypeState: GlobalState<AuthenticationType | null>;
|
||||
private loginStrategyCacheState: GlobalState<CacheData | null>;
|
||||
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
|
||||
|
@ -111,6 +113,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
protected taskSchedulerService: TaskSchedulerService,
|
||||
) {
|
||||
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
|
||||
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
|
||||
|
@ -118,6 +121,10 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||
this.authRequestPushNotificationState = this.stateProvider.get(
|
||||
AUTH_REQUEST_PUSH_NOTIFICATION_KEY,
|
||||
);
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
() => this.clearCache(),
|
||||
);
|
||||
|
||||
this.currentAuthType$ = this.currentAuthnTypeState.state$;
|
||||
this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe(
|
||||
|
@ -268,15 +275,23 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||
|
||||
private async startSessionTimeout(): Promise<void> {
|
||||
await this.clearSessionTimeout();
|
||||
|
||||
// This Login Strategy Cache Expiration State value set here is used to clear the cache on re-init
|
||||
// of the application in the case where the timeout is terminated due to a closure of the application
|
||||
// window. The browser extension popup in particular is susceptible to this concern, as the user
|
||||
// is almost always likely to close the popup window before the session timeout is reached.
|
||||
await this.loginStrategyCacheExpirationState.update(
|
||||
(_) => new Date(Date.now() + sessionTimeoutLength),
|
||||
);
|
||||
this.sessionTimeout = setTimeout(() => this.clearCache(), sessionTimeoutLength);
|
||||
this.sessionTimeoutSubscription = this.taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
sessionTimeoutLength,
|
||||
);
|
||||
}
|
||||
|
||||
private async clearSessionTimeout(): Promise<void> {
|
||||
await this.loginStrategyCacheExpirationState.update((_) => null);
|
||||
this.sessionTimeout = null;
|
||||
this.sessionTimeoutSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
private async isSessionValid(): Promise<boolean> {
|
||||
|
@ -284,6 +299,9 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||
if (cache == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the Login Strategy Cache Expiration State value is less than the current
|
||||
// datetime stamp, then the cache is invalid and should be cleared.
|
||||
const expiration = await firstValueFrom(this.loginStrategyCacheExpirationState.state$);
|
||||
if (expiration != null && expiration < new Date()) {
|
||||
await this.clearCache();
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum";
|
||||
|
||||
import { DefaultTaskSchedulerService } from "./default-task-scheduler.service";
|
||||
|
||||
describe("DefaultTaskSchedulerService", () => {
|
||||
const callback = jest.fn();
|
||||
const delayInMs = 1000;
|
||||
const intervalInMs = 1100;
|
||||
let logService: MockProxy<LogService>;
|
||||
let taskSchedulerService: DefaultTaskSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
logService = mock<LogService>();
|
||||
taskSchedulerService = new DefaultTaskSchedulerService(logService);
|
||||
taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
callback,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("triggers an error when setting a timeout for a task that is not registered", async () => {
|
||||
expect(() =>
|
||||
taskSchedulerService.setTimeout(ScheduledTaskNames.notificationsReconnectTimeout, 1000),
|
||||
).toThrow(
|
||||
`Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers an error when setting an interval for a task that is not registered", async () => {
|
||||
expect(() =>
|
||||
taskSchedulerService.setInterval(ScheduledTaskNames.notificationsReconnectTimeout, 1000),
|
||||
).toThrow(
|
||||
`Task handler for ${ScheduledTaskNames.notificationsReconnectTimeout} not registered. Unable to schedule task.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("overrides the handler for a previously registered task and provides a warning about the task registration", () => {
|
||||
taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
callback,
|
||||
);
|
||||
|
||||
expect(logService.warning).toHaveBeenCalledWith(
|
||||
`Task handler for ${ScheduledTaskNames.loginStrategySessionTimeout} already exists. Overwriting.`,
|
||||
);
|
||||
expect(
|
||||
taskSchedulerService["taskHandlers"].get(ScheduledTaskNames.loginStrategySessionTimeout),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets a timeout and returns the timeout id", () => {
|
||||
const timeoutId = taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs,
|
||||
);
|
||||
|
||||
expect(timeoutId).toBeDefined();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(delayInMs);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets an interval timeout and results the interval id", () => {
|
||||
const intervalId = taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs,
|
||||
);
|
||||
|
||||
expect(intervalId).toBeDefined();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(intervalInMs);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(intervalInMs);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clears scheduled tasks using the timeout id", () => {
|
||||
const timeoutHandle = taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
delayInMs,
|
||||
);
|
||||
|
||||
expect(timeoutHandle).toBeDefined();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
timeoutHandle.unsubscribe();
|
||||
|
||||
jest.advanceTimersByTime(delayInMs);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears scheduled tasks using the interval id", () => {
|
||||
const intervalHandle = taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
intervalInMs,
|
||||
);
|
||||
|
||||
expect(intervalHandle).toBeDefined();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
intervalHandle.unsubscribe();
|
||||
|
||||
jest.advanceTimersByTime(intervalInMs);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,97 @@
|
|||
import { Subscription } from "rxjs";
|
||||
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { ScheduledTaskName } from "../scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../scheduling/task-scheduler.service";
|
||||
|
||||
export class DefaultTaskSchedulerService extends TaskSchedulerService {
|
||||
constructor(protected logService: LogService) {
|
||||
super();
|
||||
|
||||
this.taskHandlers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a timeout and returns the timeout id.
|
||||
*
|
||||
* @param taskName - The name of the task. Unused in the base implementation.
|
||||
* @param delayInMs - The delay in milliseconds.
|
||||
*/
|
||||
setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription {
|
||||
this.validateRegisteredTask(taskName);
|
||||
|
||||
const timeoutHandle = globalThis.setTimeout(() => this.triggerTask(taskName), delayInMs);
|
||||
return new Subscription(() => globalThis.clearTimeout(timeoutHandle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an interval and returns the interval id.
|
||||
*
|
||||
* @param taskName - The name of the task. Unused in the base implementation.
|
||||
* @param intervalInMs - The interval in milliseconds.
|
||||
* @param _initialDelayInMs - The initial delay in milliseconds. Unused in the base implementation.
|
||||
*/
|
||||
setInterval(
|
||||
taskName: ScheduledTaskName,
|
||||
intervalInMs: number,
|
||||
_initialDelayInMs?: number,
|
||||
): Subscription {
|
||||
this.validateRegisteredTask(taskName);
|
||||
|
||||
const intervalHandle = globalThis.setInterval(() => this.triggerTask(taskName), intervalInMs);
|
||||
|
||||
return new Subscription(() => globalThis.clearInterval(intervalHandle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a task handler.
|
||||
*
|
||||
* @param taskName - The name of the task.
|
||||
* @param handler - The task handler.
|
||||
*/
|
||||
registerTaskHandler(taskName: ScheduledTaskName, handler: () => void) {
|
||||
const existingHandler = this.taskHandlers.get(taskName);
|
||||
if (existingHandler) {
|
||||
this.logService.warning(`Task handler for ${taskName} already exists. Overwriting.`);
|
||||
this.unregisterTaskHandler(taskName);
|
||||
}
|
||||
|
||||
this.taskHandlers.set(taskName, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a task handler.
|
||||
*
|
||||
* @param taskName - The name of the task.
|
||||
*/
|
||||
unregisterTaskHandler(taskName: ScheduledTaskName) {
|
||||
this.taskHandlers.delete(taskName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a task.
|
||||
*
|
||||
* @param taskName - The name of the task.
|
||||
* @param _periodInMinutes - The period in minutes. Unused in the base implementation.
|
||||
*/
|
||||
protected async triggerTask(
|
||||
taskName: ScheduledTaskName,
|
||||
_periodInMinutes?: number,
|
||||
): Promise<void> {
|
||||
const handler = this.taskHandlers.get(taskName);
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a task handler is registered.
|
||||
*
|
||||
* @param taskName - The name of the task.
|
||||
*/
|
||||
protected validateRegisteredTask(taskName: ScheduledTaskName): void {
|
||||
if (!this.taskHandlers.has(taskName)) {
|
||||
throw new Error(`Task handler for ${taskName} not registered. Unable to schedule task.`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export { TaskSchedulerService } from "./task-scheduler.service";
|
||||
export { DefaultTaskSchedulerService } from "./default-task-scheduler.service";
|
||||
export { ScheduledTaskNames, ScheduledTaskName } from "./scheduled-task-name.enum";
|
|
@ -0,0 +1,12 @@
|
|||
export const ScheduledTaskNames = {
|
||||
generatePasswordClearClipboardTimeout: "generatePasswordClearClipboardTimeout",
|
||||
systemClearClipboardTimeout: "systemClearClipboardTimeout",
|
||||
loginStrategySessionTimeout: "loginStrategySessionTimeout",
|
||||
notificationsReconnectTimeout: "notificationsReconnectTimeout",
|
||||
fido2ClientAbortTimeout: "fido2ClientAbortTimeout",
|
||||
scheduleNextSyncInterval: "scheduleNextSyncInterval",
|
||||
eventUploadsInterval: "eventUploadsInterval",
|
||||
vaultTimeoutCheckInterval: "vaultTimeoutCheckInterval",
|
||||
} as const;
|
||||
|
||||
export type ScheduledTaskName = (typeof ScheduledTaskNames)[keyof typeof ScheduledTaskNames];
|
|
@ -0,0 +1,16 @@
|
|||
import { Subscription } from "rxjs";
|
||||
|
||||
import { ScheduledTaskName } from "./scheduled-task-name.enum";
|
||||
|
||||
export abstract class TaskSchedulerService {
|
||||
protected taskHandlers: Map<string, () => void>;
|
||||
abstract setTimeout(taskName: ScheduledTaskName, delayInMs: number): Subscription;
|
||||
abstract setInterval(
|
||||
taskName: ScheduledTaskName,
|
||||
intervalInMs: number,
|
||||
initialDelayInMs?: number,
|
||||
): Subscription;
|
||||
abstract registerTaskHandler(taskName: ScheduledTaskName, handler: () => void): void;
|
||||
abstract unregisterTaskHandler(taskName: ScheduledTaskName): void;
|
||||
protected abstract triggerTask(taskName: ScheduledTaskName, periodInMinutes?: number): void;
|
||||
}
|
|
@ -4,6 +4,7 @@ import { of } from "rxjs";
|
|||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import {
|
||||
|
@ -17,7 +18,7 @@ import {
|
|||
CreateCredentialParams,
|
||||
FallbackRequestedError,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { TaskSchedulerService } from "../../scheduling/task-scheduler.service";
|
||||
|
||||
import * as DomainUtils from "./domain-utils";
|
||||
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
||||
|
@ -35,6 +36,7 @@ describe("FidoAuthenticatorService", () => {
|
|||
let authService!: MockProxy<AuthService>;
|
||||
let vaultSettingsService: MockProxy<VaultSettingsService>;
|
||||
let domainSettingsService: MockProxy<DomainSettingsService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
let client!: Fido2ClientService;
|
||||
let tab!: chrome.tabs.Tab;
|
||||
let isValidRpId!: jest.SpyInstance;
|
||||
|
@ -45,6 +47,7 @@ describe("FidoAuthenticatorService", () => {
|
|||
authService = mock<AuthService>();
|
||||
vaultSettingsService = mock<VaultSettingsService>();
|
||||
domainSettingsService = mock<DomainSettingsService>();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
|
||||
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
|
||||
|
||||
|
@ -54,6 +57,7 @@ describe("FidoAuthenticatorService", () => {
|
|||
authService,
|
||||
vaultSettingsService,
|
||||
domainSettingsService,
|
||||
taskSchedulerService,
|
||||
);
|
||||
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
||||
vaultSettingsService.enablePasskeys$ = of(true);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, Subscription } from "rxjs";
|
||||
import { parse } from "tldts";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
|
@ -27,6 +27,8 @@ import {
|
|||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
import { ScheduledTaskNames } from "../../scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../../scheduling/task-scheduler.service";
|
||||
|
||||
import { isValidRpId } from "./domain-utils";
|
||||
import { Fido2Utils } from "./fido2-utils";
|
||||
|
@ -38,14 +40,33 @@ import { Fido2Utils } from "./fido2-utils";
|
|||
* It is highly recommended that the W3C specification is used a reference when reading this code.
|
||||
*/
|
||||
export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||
private timeoutAbortController: AbortController;
|
||||
private readonly TIMEOUTS = {
|
||||
NO_VERIFICATION: {
|
||||
DEFAULT: 120000,
|
||||
MIN: 30000,
|
||||
MAX: 180000,
|
||||
},
|
||||
WITH_VERIFICATION: {
|
||||
DEFAULT: 300000,
|
||||
MIN: 30000,
|
||||
MAX: 600000,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
private authenticator: Fido2AuthenticatorService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
private logService?: LogService,
|
||||
) {}
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.fido2ClientAbortTimeout, () =>
|
||||
this.timeoutAbortController?.abort(),
|
||||
);
|
||||
}
|
||||
|
||||
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
|
||||
const isUserLoggedIn =
|
||||
|
@ -161,7 +182,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
const timeout = setAbortTimeout(
|
||||
const timeoutSubscription = this.setAbortTimeout(
|
||||
abortController,
|
||||
params.authenticatorSelection?.userVerification,
|
||||
params.timeout,
|
||||
|
@ -210,7 +231,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||
};
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeoutSubscription?.unsubscribe();
|
||||
|
||||
return {
|
||||
credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),
|
||||
attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject),
|
||||
|
@ -273,7 +295,11 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
|
||||
const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout);
|
||||
const timeoutSubscription = this.setAbortTimeout(
|
||||
abortController,
|
||||
params.userVerification,
|
||||
params.timeout,
|
||||
);
|
||||
|
||||
let getAssertionResult;
|
||||
try {
|
||||
|
@ -310,7 +336,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
|
||||
timeoutSubscription?.unsubscribe();
|
||||
|
||||
return {
|
||||
authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData),
|
||||
|
@ -323,43 +350,29 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||
signature: Fido2Utils.bufferToString(getAssertionResult.signature),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TIMEOUTS = {
|
||||
NO_VERIFICATION: {
|
||||
DEFAULT: 120000,
|
||||
MIN: 30000,
|
||||
MAX: 180000,
|
||||
},
|
||||
WITH_VERIFICATION: {
|
||||
DEFAULT: 300000,
|
||||
MIN: 30000,
|
||||
MAX: 600000,
|
||||
},
|
||||
};
|
||||
private setAbortTimeout = (
|
||||
abortController: AbortController,
|
||||
userVerification?: UserVerification,
|
||||
timeout?: number,
|
||||
): Subscription => {
|
||||
let clampedTimeout: number;
|
||||
|
||||
function setAbortTimeout(
|
||||
abortController: AbortController,
|
||||
userVerification?: UserVerification,
|
||||
timeout?: number,
|
||||
): number {
|
||||
let clampedTimeout: number;
|
||||
const { WITH_VERIFICATION, NO_VERIFICATION } = this.TIMEOUTS;
|
||||
if (userVerification === "required") {
|
||||
timeout = timeout ?? WITH_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(WITH_VERIFICATION.MIN, Math.min(timeout, WITH_VERIFICATION.MAX));
|
||||
} else {
|
||||
timeout = timeout ?? NO_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(NO_VERIFICATION.MIN, Math.min(timeout, NO_VERIFICATION.MAX));
|
||||
}
|
||||
|
||||
if (userVerification === "required") {
|
||||
timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(
|
||||
TIMEOUTS.WITH_VERIFICATION.MIN,
|
||||
Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX),
|
||||
this.timeoutAbortController = abortController;
|
||||
return this.taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.fido2ClientAbortTimeout,
|
||||
clampedTimeout,
|
||||
);
|
||||
} else {
|
||||
timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(
|
||||
TIMEOUTS.NO_VERIFICATION.MIN,
|
||||
Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX),
|
||||
);
|
||||
}
|
||||
|
||||
return self.setTimeout(() => abortController.abort(), clampedTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { firstValueFrom, map, timeout } from "rxjs";
|
||||
import { firstValueFrom, map, Subscription, timeout } from "rxjs";
|
||||
|
||||
import { PinServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
|
@ -13,10 +13,12 @@ import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
|||
import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service";
|
||||
import { BiometricStateService } from "../biometrics/biometric-state.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { ScheduledTaskNames } from "../scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../scheduling/task-scheduler.service";
|
||||
|
||||
export class SystemService implements SystemServiceAbstraction {
|
||||
private reloadInterval: any = null;
|
||||
private clearClipboardTimeout: any = null;
|
||||
private clearClipboardTimeoutSubscription: Subscription;
|
||||
private clearClipboardTimeoutFunction: () => Promise<any> = null;
|
||||
|
||||
constructor(
|
||||
|
@ -28,7 +30,13 @@ export class SystemService implements SystemServiceAbstraction {
|
|||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.systemClearClipboardTimeout,
|
||||
() => this.clearPendingClipboard(),
|
||||
);
|
||||
}
|
||||
|
||||
async startProcessReload(authService: AuthService): Promise<void> {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
|
@ -111,25 +119,22 @@ export class SystemService implements SystemServiceAbstraction {
|
|||
}
|
||||
|
||||
async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise<void> {
|
||||
if (this.clearClipboardTimeout != null) {
|
||||
clearTimeout(this.clearClipboardTimeout);
|
||||
this.clearClipboardTimeout = null;
|
||||
}
|
||||
this.clearClipboardTimeoutSubscription?.unsubscribe();
|
||||
|
||||
if (Utils.isNullOrWhitespace(clipboardValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearClipboardDelay = await firstValueFrom(
|
||||
this.autofillSettingsService.clearClipboardDelay$,
|
||||
);
|
||||
|
||||
if (clearClipboardDelay == null) {
|
||||
return;
|
||||
let taskTimeoutInMs = timeoutMs;
|
||||
if (!taskTimeoutInMs) {
|
||||
const clearClipboardDelayInSeconds = await firstValueFrom(
|
||||
this.autofillSettingsService.clearClipboardDelay$,
|
||||
);
|
||||
taskTimeoutInMs = clearClipboardDelayInSeconds ? clearClipboardDelayInSeconds * 1000 : null;
|
||||
}
|
||||
|
||||
if (timeoutMs == null) {
|
||||
timeoutMs = clearClipboardDelay * 1000;
|
||||
if (!taskTimeoutInMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearClipboardTimeoutFunction = async () => {
|
||||
|
@ -139,9 +144,10 @@ export class SystemService implements SystemServiceAbstraction {
|
|||
}
|
||||
};
|
||||
|
||||
this.clearClipboardTimeout = setTimeout(async () => {
|
||||
await this.clearPendingClipboard();
|
||||
}, timeoutMs);
|
||||
this.clearClipboardTimeoutSubscription = this.taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.systemClearClipboardTimeout,
|
||||
taskTimeoutInMs,
|
||||
);
|
||||
}
|
||||
|
||||
async clearPendingClipboard() {
|
||||
|
|
|
@ -112,6 +112,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", { web: "disk-local" });
|
||||
export const TASK_SCHEDULER_DISK = new StateDefinition("taskScheduler", "disk");
|
||||
|
||||
// Secrets Manager
|
||||
|
||||
|
|
|
@ -7,6 +7,8 @@ import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
|||
import { EventData } from "../../models/data/event.data";
|
||||
import { EventRequest } from "../../models/request/event.request";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { ScheduledTaskNames } from "../../platform/scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../../platform/scheduling/task-scheduler.service";
|
||||
import { StateProvider } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
|
@ -19,7 +21,12 @@ export class EventUploadService implements EventUploadServiceAbstraction {
|
|||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.eventUploadsInterval, () =>
|
||||
this.uploadEvents(),
|
||||
);
|
||||
}
|
||||
|
||||
init(checkOnInterval: boolean) {
|
||||
if (this.inited) {
|
||||
|
@ -28,10 +35,11 @@ export class EventUploadService implements EventUploadServiceAbstraction {
|
|||
|
||||
this.inited = true;
|
||||
if (checkOnInterval) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.uploadEvents();
|
||||
setInterval(() => this.uploadEvents(), 60 * 1000); // check every 60 seconds
|
||||
void this.uploadEvents();
|
||||
this.taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.eventUploadsInterval,
|
||||
60 * 1000, // check every 60 seconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as signalR from "@microsoft/signalr";
|
||||
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, Subscription } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
|
@ -20,6 +20,8 @@ import { EnvironmentService } from "../platform/abstractions/environment.service
|
|||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../platform/abstractions/state.service";
|
||||
import { ScheduledTaskNames } from "../platform/scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../platform/scheduling/task-scheduler.service";
|
||||
import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
|
@ -28,7 +30,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||
private connected = false;
|
||||
private inited = false;
|
||||
private inactive = false;
|
||||
private reconnectTimer: any = null;
|
||||
private reconnectTimerSubscription: Subscription;
|
||||
private isSyncingOnReconnect = true;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
|
@ -40,7 +43,12 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||
private stateService: StateService,
|
||||
private authService: AuthService,
|
||||
private messagingService: MessagingService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||
() => this.reconnect(this.isSyncingOnReconnect),
|
||||
);
|
||||
this.environmentService.environment$.subscribe(() => {
|
||||
if (!this.inited) {
|
||||
return;
|
||||
|
@ -213,10 +221,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||
}
|
||||
|
||||
private async reconnect(sync: boolean) {
|
||||
if (this.reconnectTimer != null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.reconnectTimerSubscription?.unsubscribe();
|
||||
|
||||
if (this.connected || !this.inited || this.inactive) {
|
||||
return;
|
||||
}
|
||||
|
@ -236,7 +242,11 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||
}
|
||||
|
||||
if (!this.connected) {
|
||||
this.reconnectTimer = setTimeout(() => this.reconnect(sync), this.random(120000, 300000));
|
||||
this.isSyncingOnReconnect = sync;
|
||||
this.reconnectTimerSubscription = this.taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||
this.random(120000, 300000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@ import { MockProxy, any, mock } from "jest-mock-extended";
|
|||
import { BehaviorSubject, from, of } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
|
@ -37,6 +39,8 @@ describe("VaultTimeoutService", () => {
|
|||
let authService: MockProxy<AuthService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
|
||||
|
||||
|
@ -60,6 +64,8 @@ describe("VaultTimeoutService", () => {
|
|||
authService = mock();
|
||||
vaultTimeoutSettingsService = mock();
|
||||
stateEventRunnerService = mock();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
lockedCallback = jest.fn();
|
||||
loggedOutCallback = jest.fn();
|
||||
|
@ -85,6 +91,8 @@ describe("VaultTimeoutService", () => {
|
|||
authService,
|
||||
vaultTimeoutSettingsService,
|
||||
stateEventRunnerService,
|
||||
taskSchedulerService,
|
||||
logService,
|
||||
lockedCallback,
|
||||
loggedOutCallback,
|
||||
);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
|
@ -35,12 +37,19 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||
private authService: AuthService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private stateEventRunnerService: StateEventRunnerService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
protected logService: LogService,
|
||||
private lockedCallback: (userId?: string) => Promise<void> = null,
|
||||
private loggedOutCallback: (
|
||||
logoutReason: LogoutReason,
|
||||
userId?: string,
|
||||
) => Promise<void> = null,
|
||||
) {}
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.vaultTimeoutCheckInterval,
|
||||
() => this.checkVaultTimeout(),
|
||||
);
|
||||
}
|
||||
|
||||
async init(checkOnInterval: boolean) {
|
||||
if (this.inited) {
|
||||
|
@ -54,10 +63,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||
}
|
||||
|
||||
startCheck() {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.checkVaultTimeout();
|
||||
setInterval(() => this.checkVaultTimeout(), 10 * 1000); // check every 10 seconds
|
||||
this.checkVaultTimeout().catch((error) => this.logService.error(error));
|
||||
this.taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.vaultTimeoutCheckInterval,
|
||||
10 * 1000, // check every 10 seconds
|
||||
);
|
||||
}
|
||||
|
||||
async checkVaultTimeout(): Promise<void> {
|
||||
|
|
Loading…
Reference in New Issue