mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-17 20:31:50 +01:00
[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";
|
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
|
|
||||||
export const clearClipboardAlarmName = "clearClipboard";
|
|
||||||
|
|
||||||
export class 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)`
|
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,
|
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
|
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 { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { firstValueFrom, Subscription } from "rxjs";
|
||||||
|
|
||||||
import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsService } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
|
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
|
|
||||||
import { setAlarmTime } from "../../platform/alarms/alarm-state";
|
|
||||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
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";
|
import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command";
|
||||||
|
|
||||||
jest.mock("../../platform/alarms/alarm-state", () => {
|
jest.mock("rxjs", () => {
|
||||||
|
const actual = jest.requireActual("rxjs");
|
||||||
return {
|
return {
|
||||||
setAlarmTime: jest.fn(),
|
...actual,
|
||||||
|
firstValueFrom: jest.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const setAlarmTimeMock = setAlarmTime as jest.Mock;
|
|
||||||
|
|
||||||
describe("GeneratePasswordToClipboardCommand", () => {
|
describe("GeneratePasswordToClipboardCommand", () => {
|
||||||
let passwordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
let passwordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||||
let autofillSettingsService: MockProxy<AutofillSettingsService>;
|
let autofillSettingsService: MockProxy<AutofillSettingsService>;
|
||||||
|
let browserTaskSchedulerService: MockProxy<BrowserTaskSchedulerService>;
|
||||||
|
|
||||||
let sut: GeneratePasswordToClipboardCommand;
|
let sut: GeneratePasswordToClipboardCommand;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
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]);
|
passwordGenerationService.getOptions.mockResolvedValue([{ length: 8 }, {} as any]);
|
||||||
|
|
||||||
@ -35,6 +50,7 @@ describe("GeneratePasswordToClipboardCommand", () => {
|
|||||||
sut = new GeneratePasswordToClipboardCommand(
|
sut = new GeneratePasswordToClipboardCommand(
|
||||||
passwordGenerationService,
|
passwordGenerationService,
|
||||||
autofillSettingsService,
|
autofillSettingsService,
|
||||||
|
browserTaskSchedulerService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -44,20 +60,24 @@ describe("GeneratePasswordToClipboardCommand", () => {
|
|||||||
|
|
||||||
describe("generatePasswordToClipboard", () => {
|
describe("generatePasswordToClipboard", () => {
|
||||||
it("has clear clipboard value", async () => {
|
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);
|
await sut.generatePasswordToClipboard({ id: 1 } as any);
|
||||||
|
jest.advanceTimersByTime(2 * 60 * 1000);
|
||||||
|
|
||||||
expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledTimes(1);
|
expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledWith(1, {
|
expect(jest.spyOn(BrowserApi, "sendTabsMessage")).toHaveBeenCalledWith(1, {
|
||||||
command: "copyText",
|
command: "copyText",
|
||||||
text: "PASSWORD",
|
text: "PASSWORD",
|
||||||
});
|
});
|
||||||
|
expect(browserTaskSchedulerService.setTimeout).toHaveBeenCalledTimes(1);
|
||||||
expect(setAlarmTimeMock).toHaveBeenCalledTimes(1);
|
expect(browserTaskSchedulerService.setTimeout).toHaveBeenCalledWith(
|
||||||
|
ScheduledTaskNames.generatePasswordClearClipboardTimeout,
|
||||||
expect(setAlarmTimeMock).toHaveBeenCalledWith(clearClipboardAlarmName, expect.any(Number));
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(ClearClipboard.run).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not have clear clipboard value", async () => {
|
it("does not have clear clipboard value", async () => {
|
||||||
@ -71,8 +91,7 @@ describe("GeneratePasswordToClipboardCommand", () => {
|
|||||||
command: "copyText",
|
command: "copyText",
|
||||||
text: "PASSWORD",
|
text: "PASSWORD",
|
||||||
});
|
});
|
||||||
|
expect(browserTaskSchedulerService.setTimeout).not.toHaveBeenCalled();
|
||||||
expect(setAlarmTimeMock).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 { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
|
import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
|
|
||||||
import { setAlarmTime } from "../../platform/alarms/alarm-state";
|
import { ClearClipboard } from "./clear-clipboard";
|
||||||
|
|
||||||
import { clearClipboardAlarmName } from "./clear-clipboard";
|
|
||||||
import { copyToClipboard } from "./copy-to-clipboard-command";
|
import { copyToClipboard } from "./copy-to-clipboard-command";
|
||||||
|
|
||||||
export class GeneratePasswordToClipboardCommand {
|
export class GeneratePasswordToClipboardCommand {
|
||||||
|
private clearClipboardSubscription: Subscription;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
private passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||||
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
private autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||||
) {}
|
private taskSchedulerService: TaskSchedulerService,
|
||||||
|
) {
|
||||||
|
this.taskSchedulerService.registerTaskHandler(
|
||||||
|
ScheduledTaskNames.generatePasswordClearClipboardTimeout,
|
||||||
|
() => ClearClipboard.run(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getClearClipboard() {
|
async getClearClipboard() {
|
||||||
return await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$);
|
return await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$);
|
||||||
@ -22,14 +29,18 @@ export class GeneratePasswordToClipboardCommand {
|
|||||||
const [options] = await this.passwordGenerationService.getOptions();
|
const [options] = await this.passwordGenerationService.getOptions();
|
||||||
const password = await this.passwordGenerationService.generatePassword(options);
|
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.
|
await copyToClipboard(tab, password);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
copyToClipboard(tab, password);
|
|
||||||
|
|
||||||
const clearClipboard = await this.getClearClipboard();
|
const clearClipboardDelayInSeconds = await this.getClearClipboard();
|
||||||
|
if (!clearClipboardDelayInSeconds) {
|
||||||
if (clearClipboard != null) {
|
return;
|
||||||
await setAlarmTime(clearClipboardAlarmName, clearClipboard * 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
function triggerTestFailure() {
|
export function triggerTestFailure() {
|
||||||
expect(true).toBe("Test has failed.");
|
expect(true).toBe("Test has failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout;
|
const scheduler = typeof setImmediate === "function" ? setImmediate : setTimeout;
|
||||||
function flushPromises() {
|
export function flushPromises() {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
scheduler(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 }));
|
globalThis.dispatchEvent(new MessageEvent("message", { data, origin, source }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMockExtensionMessage(
|
export function sendMockExtensionMessage(
|
||||||
message: any,
|
message: any,
|
||||||
sender?: chrome.runtime.MessageSender,
|
sender?: chrome.runtime.MessageSender,
|
||||||
sendResponse?: CallableFunction,
|
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(
|
(chrome.runtime.onConnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||||
(call) => {
|
(call) => {
|
||||||
const callback = call[0];
|
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) => {
|
(port.onMessage.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||||
const callback = call[0];
|
const callback = call[0];
|
||||||
callback(message || {}, port);
|
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) => {
|
(port.onDisconnect.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||||
const callback = call[0];
|
const callback = call[0];
|
||||||
callback(port);
|
callback(port);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerWindowOnFocusedChangedEvent(windowId: number) {
|
export function triggerWindowOnFocusedChangedEvent(windowId: number) {
|
||||||
(chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
(chrome.windows.onFocusChanged.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||||
(call) => {
|
(call) => {
|
||||||
const callback = call[0];
|
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(
|
(chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
|
||||||
(call) => {
|
(call) => {
|
||||||
const callback = call[0];
|
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) => {
|
(chrome.tabs.onReplaced.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||||
const callback = call[0];
|
const callback = call[0];
|
||||||
callback(addedTabId, removedTabId);
|
callback(addedTabId, removedTabId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerTabOnUpdatedEvent(
|
export function triggerTabOnUpdatedEvent(
|
||||||
tabId: number,
|
tabId: number,
|
||||||
changeInfo: chrome.tabs.TabChangeInfo,
|
changeInfo: chrome.tabs.TabChangeInfo,
|
||||||
tab: chrome.tabs.Tab,
|
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) => {
|
(chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => {
|
||||||
const callback = call[0];
|
const callback = call[0];
|
||||||
callback(tabId, removeInfo);
|
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;
|
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
||||||
document.querySelectorAll = function (selector: string) {
|
document.querySelectorAll = function (selector: string) {
|
||||||
return originalDocumentQuerySelectorAll.call(
|
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 { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
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 { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.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 */
|
/* eslint-enable no-restricted-imports */
|
||||||
import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document";
|
import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document";
|
||||||
import { DefaultOffscreenDocumentService } from "../platform/offscreen-document/offscreen-document.service";
|
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 { BrowserCryptoService } from "../platform/services/browser-crypto.service";
|
||||||
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||||
import BrowserLocalStorageService from "../platform/services/browser-local-storage.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 { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
||||||
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
|
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
|
||||||
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-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 { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service";
|
||||||
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
|
import { BrowserStorageServiceProvider } from "../platform/storage/browser-storage-service.provider";
|
||||||
import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service";
|
import { ForegroundMemoryStorageService } from "../platform/storage/foreground-memory-storage.service";
|
||||||
@ -322,6 +326,7 @@ export default class MainBackground {
|
|||||||
activeUserStateProvider: ActiveUserStateProvider;
|
activeUserStateProvider: ActiveUserStateProvider;
|
||||||
derivedStateProvider: DerivedStateProvider;
|
derivedStateProvider: DerivedStateProvider;
|
||||||
stateProvider: StateProvider;
|
stateProvider: StateProvider;
|
||||||
|
taskSchedulerService: BrowserTaskSchedulerService;
|
||||||
fido2Background: Fido2BackgroundAbstraction;
|
fido2Background: Fido2BackgroundAbstraction;
|
||||||
individualVaultExportService: IndividualVaultExportServiceAbstraction;
|
individualVaultExportService: IndividualVaultExportServiceAbstraction;
|
||||||
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
|
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
|
||||||
@ -511,6 +516,14 @@ export default class MainBackground {
|
|||||||
this.globalStateProvider,
|
this.globalStateProvider,
|
||||||
this.derivedStateProvider,
|
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.environmentService = new BrowserEnvironmentService(
|
||||||
this.logService,
|
this.logService,
|
||||||
this.stateProvider,
|
this.stateProvider,
|
||||||
@ -779,6 +792,8 @@ export default class MainBackground {
|
|||||||
this.authService,
|
this.authService,
|
||||||
this.vaultTimeoutSettingsService,
|
this.vaultTimeoutSettingsService,
|
||||||
this.stateEventRunnerService,
|
this.stateEventRunnerService,
|
||||||
|
this.taskSchedulerService,
|
||||||
|
this.logService,
|
||||||
lockedCallback,
|
lockedCallback,
|
||||||
logoutCallback,
|
logoutCallback,
|
||||||
);
|
);
|
||||||
@ -858,6 +873,7 @@ export default class MainBackground {
|
|||||||
this.stateProvider,
|
this.stateProvider,
|
||||||
this.logService,
|
this.logService,
|
||||||
this.authService,
|
this.authService,
|
||||||
|
this.taskSchedulerService,
|
||||||
);
|
);
|
||||||
this.eventCollectionService = new EventCollectionService(
|
this.eventCollectionService = new EventCollectionService(
|
||||||
this.cipherService,
|
this.cipherService,
|
||||||
@ -935,6 +951,7 @@ export default class MainBackground {
|
|||||||
this.stateService,
|
this.stateService,
|
||||||
this.authService,
|
this.authService,
|
||||||
this.messagingService,
|
this.messagingService,
|
||||||
|
this.taskSchedulerService,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||||
@ -950,16 +967,17 @@ export default class MainBackground {
|
|||||||
this.authService,
|
this.authService,
|
||||||
this.vaultSettingsService,
|
this.vaultSettingsService,
|
||||||
this.domainSettingsService,
|
this.domainSettingsService,
|
||||||
|
this.taskSchedulerService,
|
||||||
this.logService,
|
this.logService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const systemUtilsServiceReloadCallback = () => {
|
const systemUtilsServiceReloadCallback = async () => {
|
||||||
const forceWindowReload =
|
const forceWindowReload =
|
||||||
this.platformUtilsService.isSafari() ||
|
this.platformUtilsService.isSafari() ||
|
||||||
this.platformUtilsService.isFirefox() ||
|
this.platformUtilsService.isFirefox() ||
|
||||||
this.platformUtilsService.isOpera();
|
this.platformUtilsService.isOpera();
|
||||||
|
await this.taskSchedulerService.clearAllScheduledTasks();
|
||||||
BrowserApi.reloadExtension(forceWindowReload ? self : null);
|
BrowserApi.reloadExtension(forceWindowReload ? self : null);
|
||||||
return Promise.resolve();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.systemService = new SystemService(
|
this.systemService = new SystemService(
|
||||||
@ -971,6 +989,7 @@ export default class MainBackground {
|
|||||||
this.vaultTimeoutSettingsService,
|
this.vaultTimeoutSettingsService,
|
||||||
this.biometricStateService,
|
this.biometricStateService,
|
||||||
this.accountService,
|
this.accountService,
|
||||||
|
this.taskSchedulerService,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Other fields
|
// Other fields
|
||||||
@ -1184,7 +1203,12 @@ export default class MainBackground {
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await this.refreshBadge();
|
await this.refreshBadge();
|
||||||
await this.fullSync(true);
|
await this.fullSync(true);
|
||||||
|
await this.taskSchedulerService.setInterval(
|
||||||
|
ScheduledTaskNames.scheduleNextSyncInterval,
|
||||||
|
5 * 60 * 1000, // check every 5 minutes
|
||||||
|
);
|
||||||
setTimeout(() => this.notificationsService.init(), 2500);
|
setTimeout(() => this.notificationsService.init(), 2500);
|
||||||
|
await this.taskSchedulerService.verifyAlarmsState();
|
||||||
resolve();
|
resolve();
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
@ -1453,17 +1477,6 @@ export default class MainBackground {
|
|||||||
|
|
||||||
if (override || lastSyncAgo >= syncInternal) {
|
if (override || lastSyncAgo >= syncInternal) {
|
||||||
await this.syncService.fullSync(override);
|
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",
|
"clipboardRead",
|
||||||
"clipboardWrite",
|
"clipboardWrite",
|
||||||
"idle",
|
"idle",
|
||||||
|
"alarms",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"webRequestBlocking",
|
"webRequestBlocking",
|
||||||
"webNavigation"
|
"webNavigation"
|
||||||
|
@ -59,6 +59,7 @@
|
|||||||
"clipboardRead",
|
"clipboardRead",
|
||||||
"clipboardWrite",
|
"clipboardWrite",
|
||||||
"idle",
|
"idle",
|
||||||
|
"alarms",
|
||||||
"scripting",
|
"scripting",
|
||||||
"offscreen",
|
"offscreen",
|
||||||
"webRequest",
|
"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";
|
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
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 { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
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 { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
|
||||||
import I18nService from "../../platform/services/i18n.service";
|
import I18nService from "../../platform/services/i18n.service";
|
||||||
import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.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 { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider";
|
||||||
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
|
import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service";
|
||||||
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
|
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
|
||||||
@ -516,6 +518,15 @@ const safeProviders: SafeProvider[] = [
|
|||||||
useClass: Fido2UserVerificationService,
|
useClass: Fido2UserVerificationService,
|
||||||
deps: [PasswordRepromptService, UserVerificationService, DialogService],
|
deps: [PasswordRepromptService, UserVerificationService, DialogService],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: TaskSchedulerService,
|
||||||
|
useExisting: ForegroundTaskSchedulerService,
|
||||||
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: ForegroundTaskSchedulerService,
|
||||||
|
useFactory: getBgService<ForegroundTaskSchedulerService>("taskSchedulerService"),
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -4,16 +4,13 @@ import { SafariApp } from "../../browser/safariApp";
|
|||||||
|
|
||||||
export default class VaultTimeoutService extends BaseVaultTimeoutService {
|
export default class VaultTimeoutService extends BaseVaultTimeoutService {
|
||||||
startCheck() {
|
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()) {
|
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.
|
this.checkVaultTimeout().catch((error) => this.logService.error(error));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
this.checkSafari().catch((error) => this.logService.error(error));
|
||||||
this.checkSafari();
|
return;
|
||||||
} else {
|
|
||||||
setInterval(() => this.checkVaultTimeout(), 10 * 1000); // check every 10 seconds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
super.startCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a work-around to safari adding an arbitrary delay to setTimeout and
|
// 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
|
// set chrome
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
i18n,
|
i18n,
|
||||||
@ -158,4 +170,5 @@ global.chrome = {
|
|||||||
offscreen,
|
offscreen,
|
||||||
permissions,
|
permissions,
|
||||||
webNavigation,
|
webNavigation,
|
||||||
|
alarms,
|
||||||
} as any;
|
} as any;
|
||||||
|
@ -65,6 +65,10 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory
|
|||||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
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 { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||||
@ -239,6 +243,7 @@ export class ServiceContainer {
|
|||||||
providerApiService: ProviderApiServiceAbstraction;
|
providerApiService: ProviderApiServiceAbstraction;
|
||||||
userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
userAutoUnlockKeyService: UserAutoUnlockKeyService;
|
||||||
kdfConfigService: KdfConfigServiceAbstraction;
|
kdfConfigService: KdfConfigServiceAbstraction;
|
||||||
|
taskSchedulerService: TaskSchedulerService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let p = null;
|
let p = null;
|
||||||
@ -543,6 +548,7 @@ export class ServiceContainer {
|
|||||||
this.stateProvider,
|
this.stateProvider,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService);
|
||||||
this.loginStrategyService = new LoginStrategyService(
|
this.loginStrategyService = new LoginStrategyService(
|
||||||
this.accountService,
|
this.accountService,
|
||||||
this.masterPasswordService,
|
this.masterPasswordService,
|
||||||
@ -568,6 +574,7 @@ export class ServiceContainer {
|
|||||||
this.billingAccountProfileStateService,
|
this.billingAccountProfileStateService,
|
||||||
this.vaultTimeoutSettingsService,
|
this.vaultTimeoutSettingsService,
|
||||||
this.kdfConfigService,
|
this.kdfConfigService,
|
||||||
|
this.taskSchedulerService,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.authService = new AuthService(
|
this.authService = new AuthService(
|
||||||
@ -642,6 +649,8 @@ export class ServiceContainer {
|
|||||||
this.authService,
|
this.authService,
|
||||||
this.vaultTimeoutSettingsService,
|
this.vaultTimeoutSettingsService,
|
||||||
this.stateEventRunnerService,
|
this.stateEventRunnerService,
|
||||||
|
this.taskSchedulerService,
|
||||||
|
this.logService,
|
||||||
lockedCallback,
|
lockedCallback,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@ -724,6 +733,7 @@ export class ServiceContainer {
|
|||||||
this.stateProvider,
|
this.stateProvider,
|
||||||
this.logService,
|
this.logService,
|
||||||
this.authService,
|
this.authService,
|
||||||
|
this.taskSchedulerService,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.eventCollectionService = new EventCollectionService(
|
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";
|
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
|
||||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
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 { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||||
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
import { SystemService } from "@bitwarden/common/platform/services/system.service";
|
||||||
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
|
||||||
@ -177,6 +178,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
VaultTimeoutSettingsService,
|
VaultTimeoutSettingsService,
|
||||||
BiometricStateService,
|
BiometricStateService,
|
||||||
AccountServiceAbstraction,
|
AccountServiceAbstraction,
|
||||||
|
TaskSchedulerService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
|
@ -157,6 +157,10 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter
|
|||||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
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 { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||||
@ -409,6 +413,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
BillingAccountProfileStateService,
|
BillingAccountProfileStateService,
|
||||||
VaultTimeoutSettingsServiceAbstraction,
|
VaultTimeoutSettingsServiceAbstraction,
|
||||||
KdfConfigServiceAbstraction,
|
KdfConfigServiceAbstraction,
|
||||||
|
TaskSchedulerService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
@ -714,6 +719,8 @@ const safeProviders: SafeProvider[] = [
|
|||||||
AuthServiceAbstraction,
|
AuthServiceAbstraction,
|
||||||
VaultTimeoutSettingsServiceAbstraction,
|
VaultTimeoutSettingsServiceAbstraction,
|
||||||
StateEventRunnerService,
|
StateEventRunnerService,
|
||||||
|
TaskSchedulerService,
|
||||||
|
LogService,
|
||||||
LOCKED_CALLBACK,
|
LOCKED_CALLBACK,
|
||||||
LOGOUT_CALLBACK,
|
LOGOUT_CALLBACK,
|
||||||
],
|
],
|
||||||
@ -812,6 +819,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
StateServiceAbstraction,
|
StateServiceAbstraction,
|
||||||
AuthServiceAbstraction,
|
AuthServiceAbstraction,
|
||||||
MessagingServiceAbstraction,
|
MessagingServiceAbstraction,
|
||||||
|
TaskSchedulerService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
@ -827,7 +835,13 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: EventUploadServiceAbstraction,
|
provide: EventUploadServiceAbstraction,
|
||||||
useClass: EventUploadService,
|
useClass: EventUploadService,
|
||||||
deps: [ApiServiceAbstraction, StateProvider, LogService, AuthServiceAbstraction],
|
deps: [
|
||||||
|
ApiServiceAbstraction,
|
||||||
|
StateProvider,
|
||||||
|
LogService,
|
||||||
|
AuthServiceAbstraction,
|
||||||
|
TaskSchedulerService,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: EventCollectionServiceAbstraction,
|
provide: EventCollectionServiceAbstraction,
|
||||||
@ -1215,6 +1229,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
new SubjectMessageSender(subject),
|
new SubjectMessageSender(subject),
|
||||||
deps: [INTRAPROCESS_MESSAGING_SUBJECT],
|
deps: [INTRAPROCESS_MESSAGING_SUBJECT],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: TaskSchedulerService,
|
||||||
|
useClass: DefaultTaskSchedulerService,
|
||||||
|
deps: [LogService],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: ProviderApiServiceAbstraction,
|
provide: ProviderApiServiceAbstraction,
|
||||||
useClass: ProviderApiService,
|
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { KdfType } from "@bitwarden/common/platform/enums";
|
import { KdfType } from "@bitwarden/common/platform/enums";
|
||||||
|
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||||
import {
|
import {
|
||||||
FakeAccountService,
|
FakeAccountService,
|
||||||
FakeGlobalState,
|
FakeGlobalState,
|
||||||
@ -72,6 +73,7 @@ describe("LoginStrategyService", () => {
|
|||||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||||
|
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||||
|
|
||||||
let stateProvider: FakeGlobalStateProvider;
|
let stateProvider: FakeGlobalStateProvider;
|
||||||
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
|
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
|
||||||
@ -103,6 +105,7 @@ describe("LoginStrategyService", () => {
|
|||||||
stateProvider = new FakeGlobalStateProvider();
|
stateProvider = new FakeGlobalStateProvider();
|
||||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||||
kdfConfigService = mock<KdfConfigService>();
|
kdfConfigService = mock<KdfConfigService>();
|
||||||
|
taskSchedulerService = mock<TaskSchedulerService>();
|
||||||
|
|
||||||
sut = new LoginStrategyService(
|
sut = new LoginStrategyService(
|
||||||
accountService,
|
accountService,
|
||||||
@ -129,6 +132,7 @@ describe("LoginStrategyService", () => {
|
|||||||
billingAccountProfileStateService,
|
billingAccountProfileStateService,
|
||||||
vaultTimeoutSettingsService,
|
vaultTimeoutSettingsService,
|
||||||
kdfConfigService,
|
kdfConfigService,
|
||||||
|
taskSchedulerService,
|
||||||
);
|
);
|
||||||
|
|
||||||
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
|
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
|
Subscription,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { KdfType } from "@bitwarden/common/platform/enums/kdf-type.enum";
|
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 { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
@ -69,7 +71,7 @@ import {
|
|||||||
const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes
|
const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||||
private sessionTimeout: unknown;
|
private sessionTimeoutSubscription: Subscription;
|
||||||
private currentAuthnTypeState: GlobalState<AuthenticationType | null>;
|
private currentAuthnTypeState: GlobalState<AuthenticationType | null>;
|
||||||
private loginStrategyCacheState: GlobalState<CacheData | null>;
|
private loginStrategyCacheState: GlobalState<CacheData | null>;
|
||||||
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
|
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
|
||||||
@ -111,6 +113,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||||
protected vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
protected vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||||
protected kdfConfigService: KdfConfigService,
|
protected kdfConfigService: KdfConfigService,
|
||||||
|
protected taskSchedulerService: TaskSchedulerService,
|
||||||
) {
|
) {
|
||||||
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
|
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
|
||||||
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
|
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
|
||||||
@ -118,6 +121,10 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
this.authRequestPushNotificationState = this.stateProvider.get(
|
this.authRequestPushNotificationState = this.stateProvider.get(
|
||||||
AUTH_REQUEST_PUSH_NOTIFICATION_KEY,
|
AUTH_REQUEST_PUSH_NOTIFICATION_KEY,
|
||||||
);
|
);
|
||||||
|
this.taskSchedulerService.registerTaskHandler(
|
||||||
|
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||||
|
() => this.clearCache(),
|
||||||
|
);
|
||||||
|
|
||||||
this.currentAuthType$ = this.currentAuthnTypeState.state$;
|
this.currentAuthType$ = this.currentAuthnTypeState.state$;
|
||||||
this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe(
|
this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe(
|
||||||
@ -268,15 +275,23 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
|
|
||||||
private async startSessionTimeout(): Promise<void> {
|
private async startSessionTimeout(): Promise<void> {
|
||||||
await this.clearSessionTimeout();
|
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(
|
await this.loginStrategyCacheExpirationState.update(
|
||||||
(_) => new Date(Date.now() + sessionTimeoutLength),
|
(_) => new Date(Date.now() + sessionTimeoutLength),
|
||||||
);
|
);
|
||||||
this.sessionTimeout = setTimeout(() => this.clearCache(), sessionTimeoutLength);
|
this.sessionTimeoutSubscription = this.taskSchedulerService.setTimeout(
|
||||||
|
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||||
|
sessionTimeoutLength,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearSessionTimeout(): Promise<void> {
|
private async clearSessionTimeout(): Promise<void> {
|
||||||
await this.loginStrategyCacheExpirationState.update((_) => null);
|
await this.loginStrategyCacheExpirationState.update((_) => null);
|
||||||
this.sessionTimeout = null;
|
this.sessionTimeoutSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isSessionValid(): Promise<boolean> {
|
private async isSessionValid(): Promise<boolean> {
|
||||||
@ -284,6 +299,9 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
if (cache == null) {
|
if (cache == null) {
|
||||||
return false;
|
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$);
|
const expiration = await firstValueFrom(this.loginStrategyCacheExpirationState.state$);
|
||||||
if (expiration != null && expiration < new Date()) {
|
if (expiration != null && expiration < new Date()) {
|
||||||
await this.clearCache();
|
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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
libs/common/src/platform/scheduling/index.ts
Normal file
3
libs/common/src/platform/scheduling/index.ts
Normal file
@ -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 { AuthService } from "../../../auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
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 { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service";
|
||||||
import { ConfigService } from "../../abstractions/config/config.service";
|
import { ConfigService } from "../../abstractions/config/config.service";
|
||||||
import {
|
import {
|
||||||
@ -17,7 +18,7 @@ import {
|
|||||||
CreateCredentialParams,
|
CreateCredentialParams,
|
||||||
FallbackRequestedError,
|
FallbackRequestedError,
|
||||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
} 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 * as DomainUtils from "./domain-utils";
|
||||||
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
||||||
@ -35,6 +36,7 @@ describe("FidoAuthenticatorService", () => {
|
|||||||
let authService!: MockProxy<AuthService>;
|
let authService!: MockProxy<AuthService>;
|
||||||
let vaultSettingsService: MockProxy<VaultSettingsService>;
|
let vaultSettingsService: MockProxy<VaultSettingsService>;
|
||||||
let domainSettingsService: MockProxy<DomainSettingsService>;
|
let domainSettingsService: MockProxy<DomainSettingsService>;
|
||||||
|
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||||
let client!: Fido2ClientService;
|
let client!: Fido2ClientService;
|
||||||
let tab!: chrome.tabs.Tab;
|
let tab!: chrome.tabs.Tab;
|
||||||
let isValidRpId!: jest.SpyInstance;
|
let isValidRpId!: jest.SpyInstance;
|
||||||
@ -45,6 +47,7 @@ describe("FidoAuthenticatorService", () => {
|
|||||||
authService = mock<AuthService>();
|
authService = mock<AuthService>();
|
||||||
vaultSettingsService = mock<VaultSettingsService>();
|
vaultSettingsService = mock<VaultSettingsService>();
|
||||||
domainSettingsService = mock<DomainSettingsService>();
|
domainSettingsService = mock<DomainSettingsService>();
|
||||||
|
taskSchedulerService = mock<TaskSchedulerService>();
|
||||||
|
|
||||||
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
|
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
|
||||||
|
|
||||||
@ -54,6 +57,7 @@ describe("FidoAuthenticatorService", () => {
|
|||||||
authService,
|
authService,
|
||||||
vaultSettingsService,
|
vaultSettingsService,
|
||||||
domainSettingsService,
|
domainSettingsService,
|
||||||
|
taskSchedulerService,
|
||||||
);
|
);
|
||||||
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
||||||
vaultSettingsService.enablePasskeys$ = of(true);
|
vaultSettingsService.enablePasskeys$ = of(true);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, Subscription } from "rxjs";
|
||||||
import { parse } from "tldts";
|
import { parse } from "tldts";
|
||||||
|
|
||||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||||
@ -27,6 +27,8 @@ import {
|
|||||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||||
import { LogService } from "../../abstractions/log.service";
|
import { LogService } from "../../abstractions/log.service";
|
||||||
import { Utils } from "../../misc/utils";
|
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 { isValidRpId } from "./domain-utils";
|
||||||
import { Fido2Utils } from "./fido2-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.
|
* It is highly recommended that the W3C specification is used a reference when reading this code.
|
||||||
*/
|
*/
|
||||||
export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
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(
|
constructor(
|
||||||
private authenticator: Fido2AuthenticatorService,
|
private authenticator: Fido2AuthenticatorService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private vaultSettingsService: VaultSettingsService,
|
private vaultSettingsService: VaultSettingsService,
|
||||||
private domainSettingsService: DomainSettingsService,
|
private domainSettingsService: DomainSettingsService,
|
||||||
|
private taskSchedulerService: TaskSchedulerService,
|
||||||
private logService?: LogService,
|
private logService?: LogService,
|
||||||
) {}
|
) {
|
||||||
|
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.fido2ClientAbortTimeout, () =>
|
||||||
|
this.timeoutAbortController?.abort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
|
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
|
||||||
const isUserLoggedIn =
|
const isUserLoggedIn =
|
||||||
@ -161,7 +182,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||||
}
|
}
|
||||||
const timeout = setAbortTimeout(
|
const timeoutSubscription = this.setAbortTimeout(
|
||||||
abortController,
|
abortController,
|
||||||
params.authenticatorSelection?.userVerification,
|
params.authenticatorSelection?.userVerification,
|
||||||
params.timeout,
|
params.timeout,
|
||||||
@ -210,7 +231,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
timeoutSubscription?.unsubscribe();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),
|
credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),
|
||||||
attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject),
|
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");
|
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;
|
let getAssertionResult;
|
||||||
try {
|
try {
|
||||||
@ -310,7 +336,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
this.logService?.info(`[Fido2Client] Aborted with AbortController`);
|
||||||
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
throw new DOMException("The operation either timed out or was not allowed.", "AbortError");
|
||||||
}
|
}
|
||||||
clearTimeout(timeout);
|
|
||||||
|
timeoutSubscription?.unsubscribe();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData),
|
authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData),
|
||||||
@ -323,43 +350,29 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
signature: Fido2Utils.bufferToString(getAssertionResult.signature),
|
signature: Fido2Utils.bufferToString(getAssertionResult.signature),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const TIMEOUTS = {
|
private setAbortTimeout = (
|
||||||
NO_VERIFICATION: {
|
abortController: AbortController,
|
||||||
DEFAULT: 120000,
|
userVerification?: UserVerification,
|
||||||
MIN: 30000,
|
timeout?: number,
|
||||||
MAX: 180000,
|
): Subscription => {
|
||||||
},
|
let clampedTimeout: number;
|
||||||
WITH_VERIFICATION: {
|
|
||||||
DEFAULT: 300000,
|
|
||||||
MIN: 30000,
|
|
||||||
MAX: 600000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function setAbortTimeout(
|
const { WITH_VERIFICATION, NO_VERIFICATION } = this.TIMEOUTS;
|
||||||
abortController: AbortController,
|
if (userVerification === "required") {
|
||||||
userVerification?: UserVerification,
|
timeout = timeout ?? WITH_VERIFICATION.DEFAULT;
|
||||||
timeout?: number,
|
clampedTimeout = Math.max(WITH_VERIFICATION.MIN, Math.min(timeout, WITH_VERIFICATION.MAX));
|
||||||
): number {
|
} else {
|
||||||
let clampedTimeout: number;
|
timeout = timeout ?? NO_VERIFICATION.DEFAULT;
|
||||||
|
clampedTimeout = Math.max(NO_VERIFICATION.MIN, Math.min(timeout, NO_VERIFICATION.MAX));
|
||||||
|
}
|
||||||
|
|
||||||
if (userVerification === "required") {
|
this.timeoutAbortController = abortController;
|
||||||
timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT;
|
return this.taskSchedulerService.setTimeout(
|
||||||
clampedTimeout = Math.max(
|
ScheduledTaskNames.fido2ClientAbortTimeout,
|
||||||
TIMEOUTS.WITH_VERIFICATION.MIN,
|
clampedTimeout,
|
||||||
Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX),
|
|
||||||
);
|
);
|
||||||
} 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 { PinServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
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 { SystemService as SystemServiceAbstraction } from "../abstractions/system.service";
|
||||||
import { BiometricStateService } from "../biometrics/biometric-state.service";
|
import { BiometricStateService } from "../biometrics/biometric-state.service";
|
||||||
import { Utils } from "../misc/utils";
|
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 {
|
export class SystemService implements SystemServiceAbstraction {
|
||||||
private reloadInterval: any = null;
|
private reloadInterval: any = null;
|
||||||
private clearClipboardTimeout: any = null;
|
private clearClipboardTimeoutSubscription: Subscription;
|
||||||
private clearClipboardTimeoutFunction: () => Promise<any> = null;
|
private clearClipboardTimeoutFunction: () => Promise<any> = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -28,7 +30,13 @@ export class SystemService implements SystemServiceAbstraction {
|
|||||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||||
private biometricStateService: BiometricStateService,
|
private biometricStateService: BiometricStateService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
) {}
|
private taskSchedulerService: TaskSchedulerService,
|
||||||
|
) {
|
||||||
|
this.taskSchedulerService.registerTaskHandler(
|
||||||
|
ScheduledTaskNames.systemClearClipboardTimeout,
|
||||||
|
() => this.clearPendingClipboard(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async startProcessReload(authService: AuthService): Promise<void> {
|
async startProcessReload(authService: AuthService): Promise<void> {
|
||||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
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> {
|
async clearClipboard(clipboardValue: string, timeoutMs: number = null): Promise<void> {
|
||||||
if (this.clearClipboardTimeout != null) {
|
this.clearClipboardTimeoutSubscription?.unsubscribe();
|
||||||
clearTimeout(this.clearClipboardTimeout);
|
|
||||||
this.clearClipboardTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Utils.isNullOrWhitespace(clipboardValue)) {
|
if (Utils.isNullOrWhitespace(clipboardValue)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearClipboardDelay = await firstValueFrom(
|
let taskTimeoutInMs = timeoutMs;
|
||||||
this.autofillSettingsService.clearClipboardDelay$,
|
if (!taskTimeoutInMs) {
|
||||||
);
|
const clearClipboardDelayInSeconds = await firstValueFrom(
|
||||||
|
this.autofillSettingsService.clearClipboardDelay$,
|
||||||
if (clearClipboardDelay == null) {
|
);
|
||||||
return;
|
taskTimeoutInMs = clearClipboardDelayInSeconds ? clearClipboardDelayInSeconds * 1000 : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeoutMs == null) {
|
if (!taskTimeoutInMs) {
|
||||||
timeoutMs = clearClipboardDelay * 1000;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearClipboardTimeoutFunction = async () => {
|
this.clearClipboardTimeoutFunction = async () => {
|
||||||
@ -139,9 +144,10 @@ export class SystemService implements SystemServiceAbstraction {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.clearClipboardTimeout = setTimeout(async () => {
|
this.clearClipboardTimeoutSubscription = this.taskSchedulerService.setTimeout(
|
||||||
await this.clearPendingClipboard();
|
ScheduledTaskNames.systemClearClipboardTimeout,
|
||||||
}, timeoutMs);
|
taskTimeoutInMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearPendingClipboard() {
|
async clearPendingClipboard() {
|
||||||
|
@ -112,6 +112,7 @@ export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
|
|||||||
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
export const ENVIRONMENT_MEMORY = new StateDefinition("environment", "memory");
|
||||||
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
|
export const THEMING_DISK = new StateDefinition("theming", "disk", { web: "disk-local" });
|
||||||
export const TRANSLATION_DISK = new StateDefinition("translation", "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
|
// Secrets Manager
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
|||||||
import { EventData } from "../../models/data/event.data";
|
import { EventData } from "../../models/data/event.data";
|
||||||
import { EventRequest } from "../../models/request/event.request";
|
import { EventRequest } from "../../models/request/event.request";
|
||||||
import { LogService } from "../../platform/abstractions/log.service";
|
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 { StateProvider } from "../../platform/state";
|
||||||
import { UserId } from "../../types/guid";
|
import { UserId } from "../../types/guid";
|
||||||
|
|
||||||
@ -19,7 +21,12 @@ export class EventUploadService implements EventUploadServiceAbstraction {
|
|||||||
private stateProvider: StateProvider,
|
private stateProvider: StateProvider,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) {}
|
private taskSchedulerService: TaskSchedulerService,
|
||||||
|
) {
|
||||||
|
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.eventUploadsInterval, () =>
|
||||||
|
this.uploadEvents(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
init(checkOnInterval: boolean) {
|
init(checkOnInterval: boolean) {
|
||||||
if (this.inited) {
|
if (this.inited) {
|
||||||
@ -28,10 +35,11 @@ export class EventUploadService implements EventUploadServiceAbstraction {
|
|||||||
|
|
||||||
this.inited = true;
|
this.inited = true;
|
||||||
if (checkOnInterval) {
|
if (checkOnInterval) {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
void this.uploadEvents();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
this.taskSchedulerService.setInterval(
|
||||||
this.uploadEvents();
|
ScheduledTaskNames.eventUploadsInterval,
|
||||||
setInterval(() => this.uploadEvents(), 60 * 1000); // check every 60 seconds
|
60 * 1000, // check every 60 seconds
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import * as signalR from "@microsoft/signalr";
|
import * as signalR from "@microsoft/signalr";
|
||||||
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
|
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, Subscription } from "rxjs";
|
||||||
|
|
||||||
import { LogoutReason } from "@bitwarden/auth/common";
|
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 { LogService } from "../platform/abstractions/log.service";
|
||||||
import { MessagingService } from "../platform/abstractions/messaging.service";
|
import { MessagingService } from "../platform/abstractions/messaging.service";
|
||||||
import { StateService } from "../platform/abstractions/state.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";
|
import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction";
|
||||||
|
|
||||||
export class NotificationsService implements NotificationsServiceAbstraction {
|
export class NotificationsService implements NotificationsServiceAbstraction {
|
||||||
@ -28,7 +30,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||||||
private connected = false;
|
private connected = false;
|
||||||
private inited = false;
|
private inited = false;
|
||||||
private inactive = false;
|
private inactive = false;
|
||||||
private reconnectTimer: any = null;
|
private reconnectTimerSubscription: Subscription;
|
||||||
|
private isSyncingOnReconnect = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
@ -40,7 +43,12 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
|
private taskSchedulerService: TaskSchedulerService,
|
||||||
) {
|
) {
|
||||||
|
this.taskSchedulerService.registerTaskHandler(
|
||||||
|
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||||
|
() => this.reconnect(this.isSyncingOnReconnect),
|
||||||
|
);
|
||||||
this.environmentService.environment$.subscribe(() => {
|
this.environmentService.environment$.subscribe(() => {
|
||||||
if (!this.inited) {
|
if (!this.inited) {
|
||||||
return;
|
return;
|
||||||
@ -213,10 +221,8 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async reconnect(sync: boolean) {
|
private async reconnect(sync: boolean) {
|
||||||
if (this.reconnectTimer != null) {
|
this.reconnectTimerSubscription?.unsubscribe();
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (this.connected || !this.inited || this.inactive) {
|
if (this.connected || !this.inited || this.inactive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -236,7 +242,11 @@ export class NotificationsService implements NotificationsServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.connected) {
|
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 { BehaviorSubject, from, of } from "rxjs";
|
||||||
|
|
||||||
import { LogoutReason } from "@bitwarden/auth/common";
|
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 { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||||
import { SearchService } from "../../abstractions/search.service";
|
import { SearchService } from "../../abstractions/search.service";
|
||||||
@ -37,6 +39,8 @@ describe("VaultTimeoutService", () => {
|
|||||||
let authService: MockProxy<AuthService>;
|
let authService: MockProxy<AuthService>;
|
||||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||||
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
|
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
|
||||||
|
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||||
|
let logService: MockProxy<LogService>;
|
||||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||||
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
|
let loggedOutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: string]>;
|
||||||
|
|
||||||
@ -60,6 +64,8 @@ describe("VaultTimeoutService", () => {
|
|||||||
authService = mock();
|
authService = mock();
|
||||||
vaultTimeoutSettingsService = mock();
|
vaultTimeoutSettingsService = mock();
|
||||||
stateEventRunnerService = mock();
|
stateEventRunnerService = mock();
|
||||||
|
taskSchedulerService = mock<TaskSchedulerService>();
|
||||||
|
logService = mock<LogService>();
|
||||||
|
|
||||||
lockedCallback = jest.fn();
|
lockedCallback = jest.fn();
|
||||||
loggedOutCallback = jest.fn();
|
loggedOutCallback = jest.fn();
|
||||||
@ -85,6 +91,8 @@ describe("VaultTimeoutService", () => {
|
|||||||
authService,
|
authService,
|
||||||
vaultTimeoutSettingsService,
|
vaultTimeoutSettingsService,
|
||||||
stateEventRunnerService,
|
stateEventRunnerService,
|
||||||
|
taskSchedulerService,
|
||||||
|
logService,
|
||||||
lockedCallback,
|
lockedCallback,
|
||||||
loggedOutCallback,
|
loggedOutCallback,
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs";
|
import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs";
|
||||||
|
|
||||||
import { LogoutReason } from "@bitwarden/auth/common";
|
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 { SearchService } from "../../abstractions/search.service";
|
||||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
@ -35,12 +37,19 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||||
private stateEventRunnerService: StateEventRunnerService,
|
private stateEventRunnerService: StateEventRunnerService,
|
||||||
|
private taskSchedulerService: TaskSchedulerService,
|
||||||
|
protected logService: LogService,
|
||||||
private lockedCallback: (userId?: string) => Promise<void> = null,
|
private lockedCallback: (userId?: string) => Promise<void> = null,
|
||||||
private loggedOutCallback: (
|
private loggedOutCallback: (
|
||||||
logoutReason: LogoutReason,
|
logoutReason: LogoutReason,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
) => Promise<void> = null,
|
) => Promise<void> = null,
|
||||||
) {}
|
) {
|
||||||
|
this.taskSchedulerService.registerTaskHandler(
|
||||||
|
ScheduledTaskNames.vaultTimeoutCheckInterval,
|
||||||
|
() => this.checkVaultTimeout(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async init(checkOnInterval: boolean) {
|
async init(checkOnInterval: boolean) {
|
||||||
if (this.inited) {
|
if (this.inited) {
|
||||||
@ -54,10 +63,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startCheck() {
|
startCheck() {
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
this.checkVaultTimeout().catch((error) => this.logService.error(error));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
this.taskSchedulerService.setInterval(
|
||||||
this.checkVaultTimeout();
|
ScheduledTaskNames.vaultTimeoutCheckInterval,
|
||||||
setInterval(() => this.checkVaultTimeout(), 10 * 1000); // check every 10 seconds
|
10 * 1000, // check every 10 seconds
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkVaultTimeout(): Promise<void> {
|
async checkVaultTimeout(): Promise<void> {
|
||||||
|
Loading…
Reference in New Issue
Block a user