diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 56e2e2dd7e..1a8c5ae5c5 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,7 +1,7 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; const IdleInterval = 60 * 5; // 5 minutes @@ -12,7 +12,7 @@ export default class IdleBackground { constructor( private vaultTimeoutService: VaultTimeoutService, - private stateService: StateService, + private stateService: BrowserStateService, private notificationsService: NotificationsService ) { this.idle = chrome.idle || (browser != null ? browser.idle : null); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 581da587e4..2d370c91bb 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -61,14 +61,11 @@ import { FolderApiService } from "@bitwarden/common/services/folder/folder-api.s import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service"; import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; -import { OrganizationService } from "@bitwarden/common/services/organization/organization.service"; import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service"; import { PolicyApiService } from "@bitwarden/common/services/policy/policy-api.service"; -import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/services/provider.service"; import { SearchService } from "@bitwarden/common/services/search.service"; import { SendService } from "@bitwarden/common/services/send.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; import { SyncService } from "@bitwarden/common/services/sync/sync.service"; import { SyncNotifierService } from "@bitwarden/common/services/sync/syncNotifier.service"; @@ -89,19 +86,22 @@ import { UpdateBadge } from "../listeners/update-badge"; import { Account } from "../models/account"; import { PopupUtilsService } from "../popup/services/popup-utils.service"; import { AutofillService as AutofillServiceAbstraction } from "../services/abstractions/autofill.service"; -import { StateService as StateServiceAbstraction } from "../services/abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "../services/abstractions/browser-state.service"; import AutofillService from "../services/autofill.service"; import { BrowserEnvironmentService } from "../services/browser-environment.service"; +import { BrowserFolderService } from "../services/browser-folder.service"; +import { BrowserOrganizationService } from "../services/browser-organization.service"; +import { BrowserPolicyService } from "../services/browser-policy.service"; +import { BrowserSettingsService } from "../services/browser-settings.service"; +import { BrowserStateService } from "../services/browser-state.service"; import { BrowserCryptoService } from "../services/browserCrypto.service"; import BrowserLocalStorageService from "../services/browserLocalStorage.service"; import BrowserMessagingService from "../services/browserMessaging.service"; import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service"; import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import { FolderService } from "../services/folders/folder.service"; import I18nService from "../services/i18n.service"; import { KeyGenerationService } from "../services/keyGeneration.service"; import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; -import { StateService } from "../services/state.service"; import { VaultFilterService } from "../services/vaultFilter.service"; import VaultTimeoutService from "../services/vaultTimeout/vaultTimeout.service"; @@ -227,7 +227,7 @@ export default class MainBackground { this.secureStorageService, new StateFactory(GlobalState, Account) ); - this.stateService = new StateService( + this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, this.memoryStorageService, @@ -282,7 +282,7 @@ export default class MainBackground { this.appIdService, (expired: boolean) => this.logout(expired) ); - this.settingsService = new SettingsService(this.stateService); + this.settingsService = new BrowserSettingsService(this.stateService); this.fileUploadService = new FileUploadService(this.logService, this.apiService); this.cipherService = new CipherService( this.cryptoService, @@ -295,7 +295,7 @@ export default class MainBackground { this.stateService, this.encryptService ); - this.folderService = new FolderService( + this.folderService = new BrowserFolderService( this.cryptoService, this.i18nService, this.cipherService, @@ -317,8 +317,8 @@ export default class MainBackground { this.stateService ); this.syncNotifierService = new SyncNotifierService(); - this.organizationService = new OrganizationService(this.stateService); - this.policyService = new PolicyService(this.stateService, this.organizationService); + this.organizationService = new BrowserOrganizationService(this.stateService); + this.policyService = new BrowserPolicyService(this.stateService, this.organizationService); this.policyApiService = new PolicyApiService( this.policyService, this.apiService, diff --git a/apps/browser/src/background/notification.background.ts b/apps/browser/src/background/notification.background.ts index 127fc5203d..2da4791857 100644 --- a/apps/browser/src/background/notification.background.ts +++ b/apps/browser/src/background/notification.background.ts @@ -15,7 +15,7 @@ import { LoginView } from "@bitwarden/common/models/view/login.view"; import { BrowserApi } from "../browser/browserApi"; import { AutofillService } from "../services/abstractions/autofill.service"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import AddChangePasswordQueueMessage from "./models/addChangePasswordQueueMessage"; import AddLoginQueueMessage from "./models/addLoginQueueMessage"; @@ -33,7 +33,7 @@ export default class NotificationBackground { private authService: AuthService, private policyService: PolicyService, private folderService: FolderService, - private stateService: StateService + private stateService: BrowserStateService ) {} async init() { diff --git a/apps/browser/src/background/service_factories/folder-service.factory.ts b/apps/browser/src/background/service_factories/folder-service.factory.ts index a7c90d234b..bb35970325 100644 --- a/apps/browser/src/background/service_factories/folder-service.factory.ts +++ b/apps/browser/src/background/service_factories/folder-service.factory.ts @@ -1,6 +1,6 @@ import { FolderService as AbstractFolderService } from "@bitwarden/common/abstractions/folder/folder.service.abstraction"; -import { FolderService } from "../../services/folders/folder.service"; +import { BrowserFolderService } from "../../services/browser-folder.service"; import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; @@ -28,7 +28,7 @@ export function folderServiceFactory( "folderService", opts, async () => - new FolderService( + new BrowserFolderService( await cryptoServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await cipherServiceFactory(cache, opts), diff --git a/apps/browser/src/background/service_factories/organization-service.factory.ts b/apps/browser/src/background/service_factories/organization-service.factory.ts index ea11d32e26..4f2eaee805 100644 --- a/apps/browser/src/background/service_factories/organization-service.factory.ts +++ b/apps/browser/src/background/service_factories/organization-service.factory.ts @@ -1,5 +1,6 @@ import { OrganizationService as AbstractOrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; -import { OrganizationService } from "@bitwarden/common/services/organization/organization.service"; + +import { BrowserOrganizationService } from "../../services/browser-organization.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; @@ -17,6 +18,6 @@ export function organizationServiceFactory( cache, "organizationService", opts, - async () => new OrganizationService(await stateServiceFactory(cache, opts)) + async () => new BrowserOrganizationService(await stateServiceFactory(cache, opts)) ); } diff --git a/apps/browser/src/background/service_factories/policy-service.factory.ts b/apps/browser/src/background/service_factories/policy-service.factory.ts index d4940bef25..d20bca3c62 100644 --- a/apps/browser/src/background/service_factories/policy-service.factory.ts +++ b/apps/browser/src/background/service_factories/policy-service.factory.ts @@ -1,5 +1,6 @@ import { PolicyService as AbstractPolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; -import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; + +import { BrowserPolicyService } from "../../services/browser-policy.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { @@ -26,7 +27,7 @@ export function policyServiceFactory( "policyService", opts, async () => - new PolicyService( + new BrowserPolicyService( await stateServiceFactory(cache, opts), await organizationServiceFactory(cache, opts) ) diff --git a/apps/browser/src/background/service_factories/settings-service.factory.ts b/apps/browser/src/background/service_factories/settings-service.factory.ts index 745a6d08d6..73e0ae5203 100644 --- a/apps/browser/src/background/service_factories/settings-service.factory.ts +++ b/apps/browser/src/background/service_factories/settings-service.factory.ts @@ -1,5 +1,6 @@ import { SettingsService as AbstractSettingsService } from "@bitwarden/common/abstractions/settings.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; + +import { BrowserSettingsService } from "../../services/browser-settings.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; @@ -16,6 +17,6 @@ export function settingsServiceFactory( cache, "settingsService", opts, - async () => new SettingsService(await stateServiceFactory(cache, opts)) + async () => new BrowserSettingsService(await stateServiceFactory(cache, opts)) ); } diff --git a/apps/browser/src/background/service_factories/state-service.factory.ts b/apps/browser/src/background/service_factories/state-service.factory.ts index 1b81567ac5..6d2c5cb4fa 100644 --- a/apps/browser/src/background/service_factories/state-service.factory.ts +++ b/apps/browser/src/background/service_factories/state-service.factory.ts @@ -2,7 +2,7 @@ import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { Account } from "../../models/account"; -import { StateService } from "../../services/state.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; @@ -34,15 +34,15 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & StateMigrationServiceInitOptions; export async function stateServiceFactory( - cache: { stateService?: StateService } & CachedServices, + cache: { stateService?: BrowserStateService } & CachedServices, opts: StateServiceInitOptions -): Promise { +): Promise { const service = await factory( cache, "stateService", opts, async () => - await new StateService( + await new BrowserStateService( await diskStorageServiceFactory(cache, opts), await secureStorageServiceFactory(cache, opts), await memoryStorageServiceFactory(cache, opts), diff --git a/apps/browser/src/clipboard/clipboard-state.ts b/apps/browser/src/clipboard/clipboard-state.ts index a1c15addc0..cfa2f9459f 100644 --- a/apps/browser/src/clipboard/clipboard-state.ts +++ b/apps/browser/src/clipboard/clipboard-state.ts @@ -1,10 +1,10 @@ -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; const clearClipboardStorageKey = "clearClipboardTime"; -export const getClearClipboardTime = async (stateService: StateService) => { +export const getClearClipboardTime = async (stateService: BrowserStateService) => { return await stateService.getFromSessionMemory(clearClipboardStorageKey); }; -export const setClearClipboardTime = async (stateService: StateService, time: number) => { +export const setClearClipboardTime = async (stateService: BrowserStateService, time: number) => { await stateService.setInSessionMemory(clearClipboardStorageKey, time); }; diff --git a/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts b/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts index e9c2141211..5ab36b06fe 100644 --- a/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts +++ b/apps/browser/src/clipboard/generate-password-to-clipboard-command.spec.ts @@ -3,7 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; import { BrowserApi } from "../browser/browserApi"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import { setClearClipboardTime } from "./clipboard-state"; import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command"; @@ -19,13 +19,13 @@ const setClearClipboardTimeMock = setClearClipboardTime as jest.Mock; describe("GeneratePasswordToClipboardCommand", () => { let passwordGenerationService: MockProxy; - let stateService: MockProxy; + let stateService: MockProxy; let sut: GeneratePasswordToClipboardCommand; beforeEach(() => { passwordGenerationService = mock(); - stateService = mock(); + stateService = mock(); passwordGenerationService.getOptions.mockResolvedValue([{ length: 8 }, {} as any]); diff --git a/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts b/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts index ca92d2c686..e6d4d6b8b0 100644 --- a/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts +++ b/apps/browser/src/clipboard/generate-password-to-clipboard-command.ts @@ -1,6 +1,6 @@ import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import { setClearClipboardTime } from "./clipboard-state"; import { copyToClipboard } from "./copy-to-clipboard-command"; @@ -8,7 +8,7 @@ import { copyToClipboard } from "./copy-to-clipboard-command"; export class GeneratePasswordToClipboardCommand { constructor( private passwordGenerationService: PasswordGenerationService, - private stateService: StateService + private stateService: BrowserStateService ) {} async generatePasswordToClipboard(tab: chrome.tabs.Tab) { diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts index cc8a561876..92c5dfb017 100644 --- a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts +++ b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts @@ -1,6 +1,6 @@ import { BehaviorSubject } from "rxjs"; -import { StateService } from "../../services/state.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { browserSession } from "./browser-session.decorator"; import { SessionStorable } from "./session-storable"; @@ -22,25 +22,25 @@ describe("browserSession decorator", () => { }); it("should create if StateService is a constructor argument", () => { - const stateService = Object.create(StateService.prototype, {}); + const stateService = Object.create(BrowserStateService.prototype, {}); @browserSession class TestClass { - constructor(private stateService: StateService) {} + constructor(private stateService: BrowserStateService) {} } expect(new TestClass(stateService)).toBeDefined(); }); describe("interaction with @sessionSync decorator", () => { - let stateService: StateService; + let stateService: BrowserStateService; @browserSession class TestClass { @sessionSync({ initializer: (s: string) => s }) private behaviorSubject = new BehaviorSubject(""); - constructor(private stateService: StateService) {} + constructor(private stateService: BrowserStateService) {} fromJSON(json: any) { this.behaviorSubject.next(json); @@ -48,7 +48,7 @@ describe("browserSession decorator", () => { } beforeEach(() => { - stateService = Object.create(StateService.prototype, {}) as StateService; + stateService = Object.create(BrowserStateService.prototype, {}) as BrowserStateService; }); it("should create a session syncer", () => { diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts index 73cdf76735..5d9d56c1d7 100644 --- a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts +++ b/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts @@ -1,6 +1,6 @@ import { Constructor } from "type-fest"; -import { StateService } from "../../services/state.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { SessionStorable } from "./session-storable"; import { SessionSyncer } from "./session-syncer"; @@ -22,7 +22,13 @@ export function browserSession>(constructor: TCto super(...args); // Require state service to be injected - const stateService = args.find((arg) => arg instanceof StateService); + const stateService: BrowserStateService = [this as any] + .concat(args) + .find( + (arg) => + typeof arg.setInSessionMemory === "function" && + typeof arg.getFromSessionMemory === "function" + ); if (!stateService) { throw new Error( `Cannot decorate ${constructor.name} with browserSession, Browser's StateService must be injected` @@ -38,7 +44,7 @@ export function browserSession>(constructor: TCto ); } - buildSyncer(metadata: SyncedItemMetadata, stateService: StateService) { + buildSyncer(metadata: SyncedItemMetadata, stateService: BrowserStateService) { const syncer = new SessionSyncer((this as any)[metadata.propertyKey], stateService, metadata); syncer.init(); return syncer; diff --git a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts index df0764528f..071322900e 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts +++ b/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts @@ -1,11 +1,12 @@ import { Jsonify } from "type-fest"; import { SessionStorable } from "./session-storable"; +import { InitializeOptions } from "./sync-item-metadata"; -class BuildOptions { +class BuildOptions> { ctor?: new () => T; - initializer?: (keyValuePair: Jsonify) => T; - initializeAsArray? = false; + initializer?: (keyValuePair: TJson) => T; + initializeAs?: InitializeOptions; } /** @@ -20,10 +21,10 @@ class BuildOptions { * @param buildOptions * Builders for the value, requires either a constructor (ctor) for your BehaviorSubject type or an * initializer function that takes a key value pair representation of the BehaviorSubject data - * and returns your instantiated BehaviorSubject value. `initializeAsArray can optionally be used to indicate + * and returns your instantiated BehaviorSubject value. `initializeAs can optionally be used to indicate * the provided initializer function should be used to build an array of values. For example, * ```ts - * \@sessionSync({ initializer: Foo.fromJSON, initializeAsArray: true }) + * \@sessionSync({ initializer: Foo.fromJSON, initializeAs: 'array' }) * ``` * is equivalent to * ``` @@ -46,7 +47,7 @@ export function sessionSync(buildOptions: BuildOptions) { sessionKey: `${prototype.constructor.name}_${propertyKey}`, ctor: buildOptions.ctor, initializer: buildOptions.initializer, - initializeAsArray: buildOptions.initializeAsArray, + initializeAs: buildOptions.initializeAs ?? "object", }); }; } diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts b/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts index 5286cece1b..00a0da433a 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts +++ b/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts @@ -1,8 +1,9 @@ +import { awaitAsync as flushAsyncObservables } from "@bitwarden/angular/../test-utils"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, ReplaySubject } from "rxjs"; import { BrowserApi } from "../../browser/browserApi"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { SessionSyncer } from "./session-syncer"; import { SyncedItemMetadata } from "./sync-item-metadata"; @@ -10,8 +11,13 @@ import { SyncedItemMetadata } from "./sync-item-metadata"; describe("session syncer", () => { const propertyKey = "behaviorSubject"; const sessionKey = "Test__" + propertyKey; - const metaData = { propertyKey, sessionKey, initializer: (s: string) => s }; - let stateService: MockProxy; + const metaData: SyncedItemMetadata = { + propertyKey, + sessionKey, + initializer: (s: string) => s, + initializeAs: "object", + }; + let stateService: MockProxy; let sut: SessionSyncer; let behaviorSubject: BehaviorSubject; @@ -23,7 +29,7 @@ describe("session syncer", () => { manifest_version: 3, }); - stateService = mock(); + stateService = mock(); sut = new SessionSyncer(behaviorSubject, stateService, metaData); }); @@ -34,53 +40,85 @@ describe("session syncer", () => { }); describe("constructor", () => { - it("should throw if behaviorSubject is not an instance of BehaviorSubject", () => { + it("should throw if subject is not an instance of Subject", () => { expect(() => { new SessionSyncer({} as any, stateService, null); - }).toThrowError("behaviorSubject must be an instance of BehaviorSubject"); + }).toThrowError("subject must inherit from Subject"); }); it("should create if either ctor or initializer is provided", () => { expect( - new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey, ctor: String }) + new SessionSyncer(behaviorSubject, stateService, { + propertyKey, + sessionKey, + ctor: String, + initializeAs: "object", + }) ).toBeDefined(); expect( new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey, initializer: (s: any) => s, + initializeAs: "object", }) ).toBeDefined(); }); it("should throw if neither ctor or initializer is provided", () => { expect(() => { - new SessionSyncer(behaviorSubject, stateService, { propertyKey, sessionKey }); + new SessionSyncer(behaviorSubject, stateService, { + propertyKey, + sessionKey, + initializeAs: "object", + }); }).toThrowError("ctor or initializer must be provided"); }); }); - describe("manifest v2 init", () => { - let observeSpy: jest.SpyInstance; - let listenForUpdatesSpy: jest.SpyInstance; - - beforeEach(() => { - observeSpy = jest.spyOn(behaviorSubject, "subscribe").mockReturnThis(); - listenForUpdatesSpy = jest.spyOn(BrowserApi, "messageListener").mockReturnValue(); - jest.spyOn(chrome.runtime, "getManifest").mockReturnValue({ - name: "bitwarden-test", - version: "0.0.0", - manifest_version: 2, - }); + describe("init", () => { + it("should ignore all updates currently in a ReplaySubject's buffer", () => { + const replaySubject = new ReplaySubject(Infinity); + replaySubject.next("1"); + replaySubject.next("2"); + replaySubject.next("3"); + sut = new SessionSyncer(replaySubject, stateService, metaData); + // block observing the subject + jest.spyOn(sut as any, "observe").mockImplementation(); sut.init(); + + expect(sut["ignoreNUpdates"]).toBe(3); }); - it("should not start observing", () => { - expect(observeSpy).not.toHaveBeenCalled(); + it("should ignore BehaviorSubject's initial value", () => { + const behaviorSubject = new BehaviorSubject("initial"); + sut = new SessionSyncer(behaviorSubject, stateService, metaData); + // block observing the subject + jest.spyOn(sut as any, "observe").mockImplementation(); + + sut.init(); + + expect(sut["ignoreNUpdates"]).toBe(1); }); - it("should not start listening", () => { - expect(listenForUpdatesSpy).not.toHaveBeenCalled(); + it("should grab an initial value from storage if it exists", () => { + stateService.hasInSessionMemory.mockResolvedValue(true); + //Block a call to update + const updateSpy = jest.spyOn(sut as any, "update").mockImplementation(); + + sut.init(); + + expect(updateSpy).toHaveBeenCalledWith(); + }); + + it("should not grab an initial value from storage if it does not exist", () => { + stateService.hasInSessionMemory.mockResolvedValue(false); + //Block a call to update + const updateSpy = jest.spyOn(sut as any, "update").mockImplementation(); + + sut.init(); + + expect(updateSpy).toHaveBeenCalledWith(); }); }); @@ -146,6 +184,7 @@ describe("session syncer", () => { stateService.getFromSessionMemory.mockResolvedValue("test"); await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" }); + await flushAsyncObservables(); expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1); expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey, builder); diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/decorators/session-sync-observable/session-syncer.ts index 2acfed2954..68294b68c3 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/decorators/session-sync-observable/session-syncer.ts @@ -1,9 +1,9 @@ -import { BehaviorSubject, concatMap, Subscription } from "rxjs"; +import { BehaviorSubject, concatMap, ReplaySubject, Subject, Subscription } from "rxjs"; import { Utils } from "@bitwarden/common/misc/utils"; import { BrowserApi } from "../../browser/browserApi"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { SyncedItemMetadata } from "./sync-item-metadata"; @@ -11,16 +11,16 @@ export class SessionSyncer { subscription: Subscription; id = Utils.newGuid(); - // everyone gets the same initial values - private ignoreNextUpdate = true; + // ignore initial values + private ignoreNUpdates = 0; constructor( - private behaviorSubject: BehaviorSubject, - private stateService: StateService, + private subject: Subject, + private stateService: BrowserStateService, private metaData: SyncedItemMetadata ) { - if (!(behaviorSubject instanceof BehaviorSubject)) { - throw new Error("behaviorSubject must be an instance of BehaviorSubject"); + if (!(subject instanceof Subject)) { + throw new Error("subject must inherit from Subject"); } if (metaData.ctor == null && metaData.initializer == null) { @@ -29,11 +29,23 @@ export class SessionSyncer { } init() { - if (BrowserApi.manifestVersion !== 3) { - return; + switch (this.subject.constructor) { + case ReplaySubject: + // ignore all updates currently in the buffer + this.ignoreNUpdates = (this.subject as any)._buffer.length; + break; + case BehaviorSubject: + this.ignoreNUpdates = 1; + break; + default: + break; } this.observe(); + if (this.stateService.hasInSessionMemory(this.metaData.sessionKey)) { + this.update(); + } + this.listenForUpdates(); } @@ -41,11 +53,11 @@ export class SessionSyncer { // This may be a memory leak. // There is no good time to unsubscribe from this observable. Hopefully Manifest V3 clears memory from temporary // contexts. If so, this is handled by destruction of the context. - this.subscription = this.behaviorSubject + this.subscription = this.subject .pipe( concatMap(async (next) => { - if (this.ignoreNextUpdate) { - this.ignoreNextUpdate = false; + if (this.ignoreNUpdates > 0) { + this.ignoreNUpdates -= 1; return; } await this.updateSession(next); @@ -66,10 +78,14 @@ export class SessionSyncer { if (message.command != this.updateMessageCommand || message.id === this.id) { return; } + this.update(); + } + + async update() { const builder = SyncedItemMetadata.builder(this.metaData); const value = await this.stateService.getFromSessionMemory(this.metaData.sessionKey, builder); - this.ignoreNextUpdate = true; - this.behaviorSubject.next(value); + this.ignoreNUpdates = 1; + this.subject.next(value); } private async updateSession(value: any) { diff --git a/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts b/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts index 2b3f4715d4..facfda32fd 100644 --- a/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts +++ b/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts @@ -1,17 +1,27 @@ +export type InitializeOptions = "array" | "record" | "object"; + export class SyncedItemMetadata { propertyKey: string; sessionKey: string; ctor?: new () => any; initializer?: (keyValuePair: any) => any; - initializeAsArray?: boolean; + initializeAs: InitializeOptions; static builder(metadata: SyncedItemMetadata): (o: any) => any { const itemBuilder = metadata.initializer != null ? metadata.initializer : (o: any) => Object.assign(new metadata.ctor(), o); - if (metadata.initializeAsArray) { + if (metadata.initializeAs === "array") { return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o)); + } else if (metadata.initializeAs === "record") { + return (keyValuePair: any) => { + const record: Record = {}; + for (const key in keyValuePair) { + record[key] = itemBuilder(keyValuePair[key]); + } + return record; + }; } else { return (keyValuePair: any) => itemBuilder(keyValuePair); } diff --git a/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts b/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts index 5cd869a5b6..12b0b57d52 100644 --- a/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts +++ b/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts @@ -8,32 +8,60 @@ describe("builder", () => { const ctor = TestClass; it("should use initializer if provided", () => { - const metadata = { propertyKey, sessionKey: key, initializer }; + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + initializer, + initializeAs: "object", + }; const builder = SyncedItemMetadata.builder(metadata); expect(builder({})).toBe("used initializer"); }); it("should use ctor if initializer is not provided", () => { - const metadata = { propertyKey, sessionKey: key, ctor }; + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + ctor, + initializeAs: "object", + }; const builder = SyncedItemMetadata.builder(metadata); expect(builder({})).toBeInstanceOf(TestClass); }); it("should prefer initializer over ctor", () => { - const metadata = { propertyKey, sessionKey: key, ctor, initializer }; + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + ctor, + initializer, + initializeAs: "object", + }; const builder = SyncedItemMetadata.builder(metadata); expect(builder({})).toBe("used initializer"); }); it("should honor initialize as array", () => { - const metadata = { + const metadata: SyncedItemMetadata = { propertyKey, sessionKey: key, initializer: initializer, - initializeAsArray: true, + initializeAs: "array", }; const builder = SyncedItemMetadata.builder(metadata); expect(builder([{}])).toBeInstanceOf(Array); expect(builder([{}])[0]).toBe("used initializer"); }); + + it("should honor initialize as record", () => { + const metadata: SyncedItemMetadata = { + propertyKey, + sessionKey: key, + initializer: initializer, + initializeAs: "record", + }; + const builder = SyncedItemMetadata.builder(metadata); + expect(builder({ key: "" })).toBeInstanceOf(Object); + expect(builder({ key: "" })).toStrictEqual({ key: "used initializer" }); + }); }); diff --git a/apps/browser/src/listeners/update-badge.ts b/apps/browser/src/listeners/update-badge.ts index 9c7c122a45..8762a15ab2 100644 --- a/apps/browser/src/listeners/update-badge.ts +++ b/apps/browser/src/listeners/update-badge.ts @@ -15,7 +15,7 @@ import { searchServiceFactory } from "../background/service_factories/search-ser import { stateServiceFactory } from "../background/service_factories/state-service.factory"; import { BrowserApi } from "../browser/browserApi"; import { Account } from "../models/account"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; export type BadgeOptions = { @@ -25,7 +25,7 @@ export type BadgeOptions = { export class UpdateBadge { private authService: AuthService; - private stateService: StateService; + private stateService: BrowserStateService; private cipherService: CipherService; private badgeAction: typeof chrome.action; private sidebarAction: OperaSidebarAction | FirefoxSidebarAction; diff --git a/apps/browser/src/models/account.ts b/apps/browser/src/models/account.ts index f49c55d290..cfbcbecf97 100644 --- a/apps/browser/src/models/account.ts +++ b/apps/browser/src/models/account.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { Account as BaseAccount, AccountSettings as BaseAccountSettings, @@ -9,6 +11,14 @@ import { BrowserSendComponentState } from "./browserSendComponentState"; export class AccountSettings extends BaseAccountSettings { vaultTimeout = -1; // On Restart + + static fromJSON(json: Jsonify): AccountSettings { + if (json == null) { + return null; + } + + return Object.assign(new AccountSettings(), json, super.fromJSON(json)); + } } export class Account extends BaseAccount { @@ -29,4 +39,18 @@ export class Account extends BaseAccount { this.ciphers = init?.ciphers ?? new BrowserComponentState(); this.sendType = init?.sendType ?? new BrowserComponentState(); } + + static fromJSON(json: Jsonify): Account { + if (json == null) { + return null; + } + + return Object.assign(new Account({}), json, super.fromJSON(json), { + settings: AccountSettings.fromJSON(json.settings), + groupings: BrowserGroupingsComponentState.fromJSON(json.groupings), + send: BrowserSendComponentState.fromJSON(json.send), + ciphers: BrowserComponentState.fromJSON(json.ciphers), + sendType: BrowserComponentState.fromJSON(json.sendType), + }); + } } diff --git a/apps/browser/src/models/browserComponentState.ts b/apps/browser/src/models/browserComponentState.ts index d968726c41..c5540d088f 100644 --- a/apps/browser/src/models/browserComponentState.ts +++ b/apps/browser/src/models/browserComponentState.ts @@ -1,4 +1,14 @@ +import { Jsonify } from "type-fest"; + export class BrowserComponentState { scrollY: number; searchText: string; + + static fromJSON(json: Jsonify) { + if (json == null) { + return null; + } + + return Object.assign(new BrowserComponentState(), json); + } } diff --git a/apps/browser/src/models/browserGroupingsComponentState.ts b/apps/browser/src/models/browserGroupingsComponentState.ts index 63eb4aaa88..f406e3d827 100644 --- a/apps/browser/src/models/browserGroupingsComponentState.ts +++ b/apps/browser/src/models/browserGroupingsComponentState.ts @@ -1,7 +1,9 @@ import { CipherType } from "@bitwarden/common/enums/cipherType"; +import { Utils } from "@bitwarden/common/misc/utils"; import { CipherView } from "@bitwarden/common/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/models/view/collection.view"; import { FolderView } from "@bitwarden/common/models/view/folder.view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; import { BrowserComponentState } from "./browserComponentState"; @@ -15,4 +17,28 @@ export class BrowserGroupingsComponentState extends BrowserComponentState { folders: FolderView[]; collections: CollectionView[]; deletedCount: number; + + toJSON() { + return Utils.merge(this, { + collectionCounts: Utils.mapToRecord(this.collectionCounts), + folderCounts: Utils.mapToRecord(this.folderCounts), + typeCounts: Utils.mapToRecord(this.typeCounts), + }); + } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new BrowserGroupingsComponentState(), json, { + favoriteCiphers: json.favoriteCiphers?.map((c) => CipherView.fromJSON(c)), + noFolderCiphers: json.noFolderCiphers?.map((c) => CipherView.fromJSON(c)), + ciphers: json.ciphers?.map((c) => CipherView.fromJSON(c)), + collectionCounts: Utils.recordToMap(json.collectionCounts), + folderCounts: Utils.recordToMap(json.folderCounts), + typeCounts: Utils.recordToMap(json.typeCounts), + folders: json.folders?.map((f) => FolderView.fromJSON(f)), + }); + } } diff --git a/apps/browser/src/models/browserSendComponentState.ts b/apps/browser/src/models/browserSendComponentState.ts index e2bf4eaa5d..99508737ab 100644 --- a/apps/browser/src/models/browserSendComponentState.ts +++ b/apps/browser/src/models/browserSendComponentState.ts @@ -1,9 +1,28 @@ import { SendType } from "@bitwarden/common/enums/sendType"; +import { Utils } from "@bitwarden/common/misc/utils"; import { SendView } from "@bitwarden/common/models/view/send.view"; +import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; import { BrowserComponentState } from "./browserComponentState"; export class BrowserSendComponentState extends BrowserComponentState { sends: SendView[]; typeCounts: Map; + + toJSON() { + return Utils.merge(this, { + typeCounts: Utils.mapToRecord(this.typeCounts), + }); + } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new BrowserSendComponentState(), json, { + sends: json.sends?.map((s) => SendView.fromJSON(s)), + typeCounts: Utils.recordToMap(json.typeCounts), + }); + } } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index ec094cbe94..da7b0062b8 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -19,7 +19,7 @@ import { MessagingService } from "@bitwarden/common/abstractions/messaging.servi import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { BrowserApi } from "../browser/browserApi"; -import { StateService } from "../services/abstractions/state.service"; +import { BrowserStateService } from "../services/abstractions/browser-state.service"; import { routerTransition } from "./app-routing.animations"; @@ -43,7 +43,7 @@ export class AppComponent implements OnInit, OnDestroy { private authService: AuthService, private i18nService: I18nService, private router: Router, - private stateService: StateService, + private stateService: BrowserStateService, private messagingService: MessagingService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, diff --git a/apps/browser/src/popup/send/send-add-edit.component.ts b/apps/browser/src/popup/send/send-add-edit.component.ts index 2fb45996ad..0355b764c3 100644 --- a/apps/browser/src/popup/send/send-add-edit.component.ts +++ b/apps/browser/src/popup/send/send-add-edit.component.ts @@ -12,7 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { SendService } from "@bitwarden/common/abstractions/send.service"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "../services/popup-utils.service"; @Component({ @@ -33,7 +33,7 @@ export class SendAddEditComponent extends BaseAddEditComponent { constructor( i18nService: I18nService, platformUtilsService: PlatformUtilsService, - stateService: StateService, + stateService: BrowserStateService, messagingService: MessagingService, policyService: PolicyService, environmentService: EnvironmentService, diff --git a/apps/browser/src/popup/send/send-groupings.component.ts b/apps/browser/src/popup/send/send-groupings.component.ts index 1af715ada0..a5d63eb9d5 100644 --- a/apps/browser/src/popup/send/send-groupings.component.ts +++ b/apps/browser/src/popup/send/send-groupings.component.ts @@ -15,7 +15,7 @@ import { SendType } from "@bitwarden/common/enums/sendType"; import { SendView } from "@bitwarden/common/models/view/send.view"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "../services/popup-utils.service"; const ComponentId = "SendComponent"; @@ -42,7 +42,7 @@ export class SendGroupingsComponent extends BaseSendComponent { policyService: PolicyService, searchService: SearchService, private popupUtils: PopupUtilsService, - private stateService: StateService, + private stateService: BrowserStateService, private router: Router, private syncService: SyncService, private changeDetectorRef: ChangeDetectorRef, @@ -165,12 +165,12 @@ export class SendGroupingsComponent extends BaseSendComponent { } private async saveState() { - this.state = { + this.state = Object.assign(new BrowserSendComponentState(), { scrollY: this.popupUtils.getContentScrollY(window), searchText: this.searchText, sends: this.sends, typeCounts: this.typeCounts, - }; + }); await this.stateService.setBrowserSendComponentState(this.state); } diff --git a/apps/browser/src/popup/send/send-type.component.ts b/apps/browser/src/popup/send/send-type.component.ts index afd3daeeda..e899ab9f00 100644 --- a/apps/browser/src/popup/send/send-type.component.ts +++ b/apps/browser/src/popup/send/send-type.component.ts @@ -16,7 +16,7 @@ import { SendType } from "@bitwarden/common/enums/sendType"; import { SendView } from "@bitwarden/common/models/view/send.view"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "../services/popup-utils.service"; const ComponentId = "SendTypeComponent"; @@ -41,7 +41,7 @@ export class SendTypeComponent extends BaseSendComponent { policyService: PolicyService, searchService: SearchService, private popupUtils: PopupUtilsService, - private stateService: StateService, + private stateService: BrowserStateService, private route: ActivatedRoute, private location: Location, private changeDetectorRef: ChangeDetectorRef, diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index a73792cc10..8008f6c88c 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService as StateServiceAbstraction } from "../../services/abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "../../services/abstractions/browser-state.service"; import { PopupUtilsService } from "./popup-utils.service"; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 236ff4e5b1..0acd8c785d 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -38,6 +38,7 @@ import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abs import { SendService } from "@bitwarden/common/abstractions/send.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; +import { StateMigrationService } from "@bitwarden/common/abstractions/stateMigration.service"; import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { TokenService } from "@bitwarden/common/abstractions/token.service"; @@ -47,6 +48,8 @@ import { UserVerificationService } from "@bitwarden/common/abstractions/userVeri import { UsernameGenerationService } from "@bitwarden/common/abstractions/usernameGeneration.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; +import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { AuthService } from "@bitwarden/common/services/auth.service"; import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; import { LoginService } from "@bitwarden/common/services/login.service"; @@ -54,9 +57,14 @@ import { SearchService } from "@bitwarden/common/services/search.service"; import MainBackground from "../../background/main.background"; import { BrowserApi } from "../../browser/browserApi"; +import { Account } from "../../models/account"; import { AutofillService } from "../../services/abstractions/autofill.service"; -import { StateService as StateServiceAbstraction } from "../../services/abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "../../services/abstractions/browser-state.service"; import { BrowserEnvironmentService } from "../../services/browser-environment.service"; +import { BrowserOrganizationService } from "../../services/browser-organization.service"; +import { BrowserPolicyService } from "../../services/browser-policy.service"; +import { BrowserSettingsService } from "../../services/browser-settings.service"; +import { BrowserStateService } from "../../services/browser-state.service"; import { BrowserFileDownloadService } from "../../services/browserFileDownloadService"; import BrowserMessagingService from "../../services/browserMessaging.service"; import BrowserMessagingPrivateModePopupService from "../../services/browserMessagingPrivateModePopup.service"; @@ -190,8 +198,13 @@ function getBgService(service: keyof MainBackground) { { provide: EventService, useFactory: getBgService("eventService"), deps: [] }, { provide: PolicyService, - useFactory: getBgService("policyService"), - deps: [], + useFactory: ( + stateService: StateServiceAbstraction, + organizationService: OrganizationService + ) => { + return new BrowserPolicyService(stateService, organizationService); + }, + deps: [StateServiceAbstraction, OrganizationService], }, { provide: PolicyApiServiceAbstraction, @@ -212,8 +225,10 @@ function getBgService(service: keyof MainBackground) { { provide: SyncService, useFactory: getBgService("syncService"), deps: [] }, { provide: SettingsService, - useFactory: getBgService("settingsService"), - deps: [], + useFactory: (stateService: StateServiceAbstraction) => { + return new BrowserSettingsService(stateService); + }, + deps: [StateServiceAbstraction], }, { provide: AbstractStorageService, @@ -261,8 +276,10 @@ function getBgService(service: keyof MainBackground) { { provide: PasswordRepromptServiceAbstraction, useClass: PasswordRepromptService }, { provide: OrganizationService, - useFactory: getBgService("organizationService"), - deps: [], + useFactory: (stateService: StateServiceAbstraction) => { + return new BrowserOrganizationService(stateService); + }, + deps: [StateServiceAbstraction], }, { provide: VaultFilterService, @@ -293,10 +310,36 @@ function getBgService(service: keyof MainBackground) { useFactory: getBgService("memoryStorageService"), }, { - provide: StateServiceAbstraction, - useFactory: getBgService("stateService"), + provide: StateMigrationService, + useFactory: getBgService("stateMigrationService"), deps: [], }, + { + provide: StateServiceAbstraction, + useFactory: ( + storageService: AbstractStorageService, + secureStorageService: AbstractStorageService, + memoryStorageService: AbstractStorageService, + logService: LogServiceAbstraction, + stateMigrationService: StateMigrationService + ) => { + return new BrowserStateService( + storageService, + secureStorageService, + memoryStorageService, + logService, + stateMigrationService, + new StateFactory(GlobalState, Account) + ); + }, + deps: [ + AbstractStorageService, + SECURE_STORAGE, + MEMORY_STORAGE, + LogServiceAbstraction, + StateMigrationService, + ], + }, { provide: UsernameGenerationService, useFactory: getBgService("usernameGenerationService"), @@ -317,17 +360,19 @@ function getBgService(service: keyof MainBackground) { }, { provide: AbstractThemingService, - useFactory: () => { + useFactory: ( + stateService: StateServiceAbstraction, + platformUtilsService: PlatformUtilsService + ) => { return new ThemingService( - getBgService("stateService")(), + stateService, // Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light. // In Safari we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed. - getBgService("platformUtilsService")().isSafari() - ? getBgService("backgroundWindow")() - : window, + platformUtilsService.isSafari() ? getBgService("backgroundWindow")() : window, document ); }, + deps: [StateServiceAbstraction, PlatformUtilsService], }, ], }) diff --git a/apps/browser/src/popup/vault/vault-filter.component.ts b/apps/browser/src/popup/vault/vault-filter.component.ts index f8a6f14081..e67fe69f60 100644 --- a/apps/browser/src/popup/vault/vault-filter.component.ts +++ b/apps/browser/src/popup/vault/vault-filter.component.ts @@ -18,7 +18,7 @@ import { FolderView } from "@bitwarden/common/models/view/folder.view"; import { BrowserApi } from "../../browser/browserApi"; import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { VaultFilterService } from "../../services/vaultFilter.service"; import { PopupUtilsService } from "../services/popup-utils.service"; @@ -83,7 +83,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private location: Location, - private browserStateService: StateService, + private browserStateService: BrowserStateService, private vaultFilterService: VaultFilterService ) { this.noFolderListSize = 100; @@ -373,7 +373,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } private async saveState() { - this.state = { + this.state = Object.assign(new BrowserGroupingsComponentState(), { scrollY: this.popupUtils.getContentScrollY(window), searchText: this.searchText, favoriteCiphers: this.favoriteCiphers, @@ -385,7 +385,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { folders: this.folders, collections: this.collections, deletedCount: this.deletedCount, - }; + }); await this.browserStateService.setBrowserGroupingComponentState(this.state); } diff --git a/apps/browser/src/popup/vault/vault-items.component.ts b/apps/browser/src/popup/vault/vault-items.component.ts index 232a29ea41..b7f63d6d71 100644 --- a/apps/browser/src/popup/vault/vault-items.component.ts +++ b/apps/browser/src/popup/vault/vault-items.component.ts @@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/models/view/folder.view"; import { BrowserApi } from "../../browser/browserApi"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { StateService } from "../../services/abstractions/state.service"; +import { BrowserStateService } from "../../services/abstractions/browser-state.service"; import { VaultFilterService } from "../../services/vaultFilter.service"; import { PopupUtilsService } from "../services/popup-utils.service"; @@ -60,7 +60,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private ngZone: NgZone, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, - private stateService: StateService, + private stateService: BrowserStateService, private popupUtils: PopupUtilsService, private i18nService: I18nService, private folderService: FolderService, diff --git a/apps/browser/src/services/abstractions/state.service.ts b/apps/browser/src/services/abstractions/browser-state.service.ts similarity index 91% rename from apps/browser/src/services/abstractions/state.service.ts rename to apps/browser/src/services/abstractions/browser-state.service.ts index 53a1b88364..afe5e2ce69 100644 --- a/apps/browser/src/services/abstractions/state.service.ts +++ b/apps/browser/src/services/abstractions/browser-state.service.ts @@ -8,7 +8,8 @@ import { BrowserComponentState } from "../../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; -export abstract class StateService extends BaseStateServiceAbstraction { +export abstract class BrowserStateService extends BaseStateServiceAbstraction { + abstract hasInSessionMemory(key: string): Promise; abstract getFromSessionMemory(key: string, deserializer?: (obj: Jsonify) => T): Promise; abstract setInSessionMemory(key: string, value: any): Promise; getBrowserGroupingComponentState: ( diff --git a/apps/browser/src/services/autofill.service.ts b/apps/browser/src/services/autofill.service.ts index 470bd584cf..f458279de4 100644 --- a/apps/browser/src/services/autofill.service.ts +++ b/apps/browser/src/services/autofill.service.ts @@ -14,7 +14,6 @@ import { BrowserApi } from "../browser/browserApi"; import AutofillField from "../models/autofillField"; import AutofillPageDetails from "../models/autofillPageDetails"; import AutofillScript from "../models/autofillScript"; -import { StateService } from "../services/abstractions/state.service"; import { AutoFillOptions, @@ -22,6 +21,7 @@ import { PageDetail, FormData, } from "./abstractions/autofill.service"; +import { BrowserStateService } from "./abstractions/browser-state.service"; import { AutoFillConstants, CreditCardAutoFillConstants, @@ -39,7 +39,7 @@ export interface GenerateFillScriptOptions { export default class AutofillService implements AutofillServiceInterface { constructor( private cipherService: CipherService, - private stateService: StateService, + private stateService: BrowserStateService, private totpService: TotpService, private eventService: EventService, private logService: LogService diff --git a/apps/browser/src/services/folders/folder.service.ts b/apps/browser/src/services/browser-folder.service.ts similarity index 57% rename from apps/browser/src/services/folders/folder.service.ts rename to apps/browser/src/services/browser-folder.service.ts index e4fc19644d..a9573ab0f7 100644 --- a/apps/browser/src/services/folders/folder.service.ts +++ b/apps/browser/src/services/browser-folder.service.ts @@ -4,12 +4,12 @@ import { Folder } from "@bitwarden/common/models/domain/folder"; import { FolderView } from "@bitwarden/common/models/view/folder.view"; import { FolderService as BaseFolderService } from "@bitwarden/common/services/folder/folder.service"; -import { browserSession, sessionSync } from "../../decorators/session-sync-observable"; +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; @browserSession -export class FolderService extends BaseFolderService { - @sessionSync({ initializer: Folder.fromJSON, initializeAsArray: true }) +export class BrowserFolderService extends BaseFolderService { + @sessionSync({ initializer: Folder.fromJSON, initializeAs: "array" }) protected _folders: BehaviorSubject; - @sessionSync({ initializer: FolderView.fromJSON, initializeAsArray: true }) + @sessionSync({ initializer: FolderView.fromJSON, initializeAs: "array" }) protected _folderViews: BehaviorSubject; } diff --git a/apps/browser/src/services/browser-organization.service.ts b/apps/browser/src/services/browser-organization.service.ts new file mode 100644 index 0000000000..63f2848e2e --- /dev/null +++ b/apps/browser/src/services/browser-organization.service.ts @@ -0,0 +1,12 @@ +import { BehaviorSubject } from "rxjs"; + +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { OrganizationService } from "@bitwarden/common/services/organization/organization.service"; + +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; + +@browserSession +export class BrowserOrganizationService extends OrganizationService { + @sessionSync({ initializer: Organization.fromJSON, initializeAs: "array" }) + protected _organizations: BehaviorSubject; +} diff --git a/apps/browser/src/services/browser-policy.service.ts b/apps/browser/src/services/browser-policy.service.ts new file mode 100644 index 0000000000..613e5c39cf --- /dev/null +++ b/apps/browser/src/services/browser-policy.service.ts @@ -0,0 +1,12 @@ +import { BehaviorSubject } from "rxjs"; + +import { Policy } from "@bitwarden/common/models/domain/policy"; +import { PolicyService } from "@bitwarden/common/services/policy/policy.service"; + +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; + +@browserSession +export class BrowserPolicyService extends PolicyService { + @sessionSync({ ctor: Policy, initializeAs: "array" }) + protected _policies: BehaviorSubject; +} diff --git a/apps/browser/src/services/browser-settings.service.ts b/apps/browser/src/services/browser-settings.service.ts new file mode 100644 index 0000000000..78b4f76ea6 --- /dev/null +++ b/apps/browser/src/services/browser-settings.service.ts @@ -0,0 +1,11 @@ +import { BehaviorSubject } from "rxjs"; + +import { AccountSettingsSettings } from "@bitwarden/common/models/domain/account"; +import { SettingsService } from "@bitwarden/common/services/settings.service"; + +import { sessionSync } from "../decorators/session-sync-observable"; + +export class BrowserSettingsService extends SettingsService { + @sessionSync({ initializer: (obj: string[][]) => obj }) + protected _settings: BehaviorSubject; +} diff --git a/apps/browser/src/services/state.service.spec.ts b/apps/browser/src/services/browser-state.service.spec.ts similarity index 77% rename from apps/browser/src/services/state.service.spec.ts rename to apps/browser/src/services/browser-state.service.spec.ts index 5bc4e6ad99..7d6f845631 100644 --- a/apps/browser/src/services/state.service.spec.ts +++ b/apps/browser/src/services/browser-state.service.spec.ts @@ -1,6 +1,6 @@ -// eslint-disable-next-line no-restricted-imports -import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; +import { mock, MockProxy } from "jest-mock-extended"; +import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MemoryStorageServiceInterface, @@ -18,28 +18,29 @@ import { BrowserComponentState } from "../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../models/browserSendComponentState"; +import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; +import { BrowserStateService } from "./browser-state.service"; import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; -import { StateService } from "./state.service"; describe("Browser State Service", () => { - let secureStorageService: SubstituteOf; - let diskStorageService: SubstituteOf; - let logService: SubstituteOf; - let stateMigrationService: SubstituteOf; - let stateFactory: SubstituteOf>; + let secureStorageService: MockProxy; + let diskStorageService: MockProxy; + let logService: MockProxy; + let stateMigrationService: MockProxy; + let stateFactory: MockProxy>; let useAccountCache: boolean; let state: State; const userId = "userId"; - let sut: StateService; + let sut: BrowserStateService; beforeEach(() => { - secureStorageService = Substitute.for(); - diskStorageService = Substitute.for(); - logService = Substitute.for(); - stateMigrationService = Substitute.for(); - stateFactory = Substitute.for(); + secureStorageService = mock(); + diskStorageService = mock(); + logService = mock(); + stateMigrationService = mock(); + stateFactory = mock(); useAccountCache = true; state = new State(new GlobalState()); @@ -54,9 +55,12 @@ describe("Browser State Service", () => { beforeEach(() => { // We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass. - memoryStorageService = Object.create(LocalBackedSessionStorageService.prototype); + memoryStorageService = new LocalBackedSessionStorageService( + mock(), + mock() + ); - sut = new StateService( + sut = new BrowserStateService( diskStorageService, secureStorageService, memoryStorageService, @@ -80,14 +84,14 @@ describe("Browser State Service", () => { }); describe("state methods", () => { - let memoryStorageService: SubstituteOf; + let memoryStorageService: MockProxy; beforeEach(() => { - memoryStorageService = Substitute.for(); - const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); - memoryStorageService.get("state", Arg.any()).mimicks(stateGetter); + memoryStorageService = mock(); + const stateGetter = (key: string) => Promise.resolve(state); + memoryStorageService.get.mockImplementation(stateGetter); - sut = new StateService( + sut = new BrowserStateService( diskStorageService, secureStorageService, memoryStorageService, @@ -128,6 +132,7 @@ describe("Browser State Service", () => { [SendType.Text, 5], ]); state.accounts[userId].send = sendState; + (global as any)["watch"] = state; const actual = await sut.getBrowserSendComponentState(); expect(actual).toBeInstanceOf(BrowserSendComponentState); diff --git a/apps/browser/src/services/state.service.ts b/apps/browser/src/services/browser-state.service.ts similarity index 81% rename from apps/browser/src/services/state.service.ts rename to apps/browser/src/services/browser-state.service.ts index 3f4161f5a6..24630dcbae 100644 --- a/apps/browser/src/services/state.service.ts +++ b/apps/browser/src/services/browser-state.service.ts @@ -1,24 +1,40 @@ +import { BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service"; import { GlobalState } from "@bitwarden/common/models/domain/global-state"; import { StorageOptions } from "@bitwarden/common/models/domain/storage-options"; -import { - StateService as BaseStateService, - withPrototype, -} from "@bitwarden/common/services/state.service"; +import { StateService as BaseStateService } from "@bitwarden/common/services/state.service"; +import { browserSession, sessionSync } from "../decorators/session-sync-observable"; import { Account } from "../models/account"; import { BrowserComponentState } from "../models/browserComponentState"; import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../models/browserSendComponentState"; -import { StateService as StateServiceAbstraction } from "./abstractions/state.service"; +import { BrowserStateService as StateServiceAbstraction } from "./abstractions/browser-state.service"; -export class StateService +@browserSession +export class BrowserStateService extends BaseStateService implements StateServiceAbstraction { + @sessionSync({ + initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account + initializeAs: "record", + }) + protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>; + @sessionSync({ ctor: String }) + protected activeAccountSubject: BehaviorSubject; + @sessionSync({ ctor: Boolean }) + protected activeAccountUnlockedSubject: BehaviorSubject; + + protected accountDeserializer = Account.fromJSON; + + async hasInSessionMemory(key: string): Promise { + return await this.memoryStorageService.has(key); + } + async getFromSessionMemory(key: string, deserializer?: (obj: Jsonify) => T): Promise { return this.memoryStorageService instanceof AbstractCachedStorageService ? await this.memoryStorageService.getBypassCache(key, { deserializer: deserializer }) @@ -44,7 +60,6 @@ export class StateService ); } - @withPrototype(BrowserGroupingsComponentState) async getBrowserGroupingComponentState( options?: StorageOptions ): Promise { @@ -67,7 +82,6 @@ export class StateService ); } - @withPrototype(BrowserComponentState) async getBrowserVaultItemsComponentState( options?: StorageOptions ): Promise { @@ -90,7 +104,6 @@ export class StateService ); } - @withPrototype(BrowserSendComponentState) async getBrowserSendComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -111,7 +124,6 @@ export class StateService ); } - @withPrototype(BrowserComponentState) async getBrowserSendTypeComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) diff --git a/libs/angular/test-utils.ts b/libs/angular/test-utils.ts new file mode 100644 index 0000000000..a2422e698f --- /dev/null +++ b/libs/angular/test-utils.ts @@ -0,0 +1,3 @@ +export async function awaitAsync(ms = 0) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/libs/common/spec/misc/utils.spec.ts b/libs/common/spec/misc/utils.spec.ts index e1de265532..fb57c33fff 100644 --- a/libs/common/spec/misc/utils.spec.ts +++ b/libs/common/spec/misc/utils.spec.ts @@ -241,4 +241,72 @@ describe("Utils Service", () => { expect(Utils.fromByteStringToArray(null)).toEqual(null); }); }); + + describe("mapToRecord", () => { + it("should handle null", () => { + expect(Utils.mapToRecord(null)).toEqual(null); + }); + + it("should handle empty map", () => { + expect(Utils.mapToRecord(new Map())).toEqual({}); + }); + + it("should handle convert a Map to a Record", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect(Utils.mapToRecord(map)).toEqual({ key1: "value1", key2: "value2" }); + }); + + it("should handle convert a Map to a Record with non-string keys", () => { + const map = new Map([ + [1, "value1"], + [2, "value2"], + ]); + const result = Utils.mapToRecord(map); + expect(result).toEqual({ 1: "value1", 2: "value2" }); + expect(Utils.recordToMap(result)).toEqual(map); + }); + + it("should not convert an object if it's not a map", () => { + const obj = { key1: "value1", key2: "value2" }; + expect(Utils.mapToRecord(obj as any)).toEqual(obj); + }); + }); + + describe("recordToMap", () => { + it("should handle null", () => { + expect(Utils.recordToMap(null)).toEqual(null); + }); + + it("should handle empty record", () => { + expect(Utils.recordToMap({})).toEqual(new Map()); + }); + + it("should handle convert a Record to a Map", () => { + const record = { key1: "value1", key2: "value2" }; + expect(Utils.recordToMap(record)).toEqual(new Map(Object.entries(record))); + }); + + it("should handle convert a Record to a Map with non-string keys", () => { + const record = { 1: "value1", 2: "value2" }; + const result = Utils.recordToMap(record); + expect(result).toEqual( + new Map([ + [1, "value1"], + [2, "value2"], + ]) + ); + expect(Utils.mapToRecord(result)).toEqual(record); + }); + + it("should not convert an object if already a map", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect(Utils.recordToMap(map as any)).toEqual(map); + }); + }); }); diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index ff21a919f1..ead692d666 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -1,5 +1,6 @@ /* eslint-disable no-useless-escape */ import { getHostname, parse } from "tldts"; +import { Merge } from "type-fest"; import { CryptoService } from "../abstractions/crypto.service"; import { EncryptService } from "../abstractions/encrypt.service"; @@ -55,6 +56,10 @@ export class Utils { } static fromB64ToArray(str: string): Uint8Array { + if (str == null) { + return null; + } + if (Utils.isNode) { return new Uint8Array(Buffer.from(str, "base64")); } else { @@ -108,6 +113,9 @@ export class Utils { } static fromBufferToB64(buffer: ArrayBuffer): string { + if (buffer == null) { + return null; + } if (Utils.isNode) { return Buffer.from(buffer).toString("base64"); } else { @@ -423,6 +431,57 @@ export class Utils { return this.global.bitwardenContainerService; } + /** + * Converts map to a Record with the same data. Inverse of recordToMap + * Useful in toJSON methods, since Maps are not serializable + * @param map + * @returns + */ + static mapToRecord(map: Map): Record { + if (map == null) { + return null; + } + if (!(map instanceof Map)) { + return map; + } + return Object.fromEntries(map); + } + + /** + * Converts record to a Map with the same data. Inverse of mapToRecord + * Useful in fromJSON methods, since Maps are not serializable + * + * Warning: If the record has string keys that are numbers, they will be converted to numbers in the map + * @param record + * @returns + */ + static recordToMap(record: Record): Map { + if (record == null) { + return null; + } else if (record instanceof Map) { + return record; + } + + const entries = Object.entries(record); + if (entries.length === 0) { + return new Map(); + } + + if (isNaN(Number(entries[0][0]))) { + return new Map(entries) as Map; + } else { + return new Map(entries.map((e) => [Number(e[0]), e[1]])) as Map; + } + } + + /** Applies Object.assign, but converts the type nicely using Type-Fest Merge */ + static merge( + destination: Destination, + source: Source + ): Merge { + return Object.assign(destination, source) as unknown as Merge; + } + private static isMobile(win: Window) { let mobile = false; ((a) => { diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index 75a1eebfeb..0715b0c49b 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -1,4 +1,4 @@ -import { Except, Jsonify } from "type-fest"; +import { Jsonify } from "type-fest"; import { AuthenticationStatus } from "../../enums/authenticationStatus"; import { KdfType } from "../../enums/kdfType"; @@ -40,7 +40,7 @@ export class EncryptionPair { } static fromJSON( - obj: Jsonify, Jsonify>>, + obj: { encrypted?: Jsonify; decrypted?: string | Jsonify }, decryptedFromJson?: (decObj: Jsonify | string) => TDecrypted, encryptedFromJson?: (encObj: Jsonify) => TEncrypted ) { @@ -123,7 +123,7 @@ export class AccountKeys { apiKeyClientSecret?: string; toJSON() { - return Object.assign(this as Except, { + return Utils.merge(this, { publicKey: Utils.fromBufferToByteString(this.publicKey), }); } @@ -251,7 +251,7 @@ export class AccountSettings { } export type AccountSettingsSettings = { - equivalentDomains?: { [id: string]: any }; + equivalentDomains?: string[][]; }; export class AccountTokens { diff --git a/libs/common/src/models/domain/organization.ts b/libs/common/src/models/domain/organization.ts index faf1ee1f97..9e1f63ccb0 100644 --- a/libs/common/src/models/domain/organization.ts +++ b/libs/common/src/models/domain/organization.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType"; import { OrganizationUserType } from "../../enums/organizationUserType"; import { ProductType } from "../../enums/productType"; @@ -201,4 +203,15 @@ export class Organization { get hasProvider() { return this.providerId != null || this.providerName != null; } + + static fromJSON(json: Jsonify) { + if (json == null) { + return null; + } + + return Object.assign(new Organization(), json, { + familySponsorshipLastSyncDate: new Date(json.familySponsorshipLastSyncDate), + familySponsorshipValidUntil: new Date(json.familySponsorshipValidUntil), + }); + } } diff --git a/libs/common/src/models/domain/state.spec.ts b/libs/common/src/models/domain/state.spec.ts index 64e71d7cb2..aa4f36549c 100644 --- a/libs/common/src/models/domain/state.spec.ts +++ b/libs/common/src/models/domain/state.spec.ts @@ -4,22 +4,25 @@ import { State } from "./state"; describe("state", () => { describe("fromJSON", () => { it("should deserialize to an instance of itself", () => { - expect(State.fromJSON({})).toBeInstanceOf(State); + expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State); }); it("should always assign an object to accounts", () => { - const state = State.fromJSON({}); + const state = State.fromJSON({}, () => new Account({})); expect(state.accounts).not.toBeNull(); expect(state.accounts).toEqual({}); }); it("should build an account map", () => { const accountsSpy = jest.spyOn(Account, "fromJSON"); - const state = State.fromJSON({ - accounts: { - userId: {}, + const state = State.fromJSON( + { + accounts: { + userId: {}, + }, }, - }); + Account.fromJSON + ); expect(state.accounts["userId"]).toBeInstanceOf(Account); expect(accountsSpy).toHaveBeenCalled(); diff --git a/libs/common/src/models/domain/state.ts b/libs/common/src/models/domain/state.ts index be99d9bdab..c08e6fe70f 100644 --- a/libs/common/src/models/domain/state.ts +++ b/libs/common/src/models/domain/state.ts @@ -19,26 +19,28 @@ export class State< // TODO, make Jsonify work. It currently doesn't because Globals doesn't implement Jsonify. static fromJSON( - obj: any + obj: any, + accountDeserializer: (json: Jsonify) => TAccount ): State { if (obj == null) { return null; } return Object.assign(new State(null), obj, { - accounts: State.buildAccountMapFromJSON(obj?.accounts), + accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer), }); } - private static buildAccountMapFromJSON( - jsonAccounts: Jsonify<{ [userId: string]: Jsonify }> + private static buildAccountMapFromJSON( + jsonAccounts: { [userId: string]: Jsonify }, + accountDeserializer: (json: Jsonify) => TAccount ) { if (!jsonAccounts) { return {}; } - const accounts: { [userId: string]: Account } = {}; + const accounts: { [userId: string]: TAccount } = {}; for (const userId in jsonAccounts) { - accounts[userId] = Account.fromJSON(jsonAccounts[userId]); + accounts[userId] = accountDeserializer(jsonAccounts[userId]); } return accounts; } diff --git a/libs/common/src/models/view/send-file.view.ts b/libs/common/src/models/view/send-file.view.ts index 7ed291a2b7..3ac12b8203 100644 --- a/libs/common/src/models/view/send-file.view.ts +++ b/libs/common/src/models/view/send-file.view.ts @@ -1,3 +1,4 @@ +import { DeepJsonify } from "../../types/deep-jsonify"; import { SendFile } from "../domain/send-file"; import { View } from "./view"; @@ -28,4 +29,12 @@ export class SendFileView implements View { } return 0; } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new SendFileView(), json); + } } diff --git a/libs/common/src/models/view/send-text.view.ts b/libs/common/src/models/view/send-text.view.ts index cd10c799b6..638f66ad66 100644 --- a/libs/common/src/models/view/send-text.view.ts +++ b/libs/common/src/models/view/send-text.view.ts @@ -1,3 +1,4 @@ +import { DeepJsonify } from "../../types/deep-jsonify"; import { SendText } from "../domain/send-text"; import { View } from "./view"; @@ -17,4 +18,12 @@ export class SendTextView implements View { get maskedText(): string { return this.text != null ? "••••••••" : null; } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new SendTextView(), json); + } } diff --git a/libs/common/src/models/view/send.view.ts b/libs/common/src/models/view/send.view.ts index 3ef6bf9f0e..c5da1d2f68 100644 --- a/libs/common/src/models/view/send.view.ts +++ b/libs/common/src/models/view/send.view.ts @@ -1,5 +1,6 @@ import { SendType } from "../../enums/sendType"; import { Utils } from "../../misc/utils"; +import { DeepJsonify } from "../../types/deep-jsonify"; import { Send } from "../domain/send"; import { SymmetricCryptoKey } from "../domain/symmetric-crypto-key"; @@ -65,4 +66,26 @@ export class SendView implements View { get pendingDelete(): boolean { return this.deletionDate <= new Date(); } + + toJSON() { + return Utils.merge(this, { + key: Utils.fromBufferToB64(this.key), + }); + } + + static fromJSON(json: DeepJsonify) { + if (json == null) { + return null; + } + + return Object.assign(new SendView(), json, { + key: Utils.fromB64ToArray(json.key)?.buffer, + cryptoKey: SymmetricCryptoKey.fromJSON(json.cryptoKey), + text: SendTextView.fromJSON(json.text), + file: SendFileView.fromJSON(json.file), + revisionDate: json.revisionDate == null ? null : new Date(json.revisionDate), + deletionDate: json.deletionDate == null ? null : new Date(json.deletionDate), + expirationDate: json.expirationDate == null ? null : new Date(json.expirationDate), + }); + } } diff --git a/libs/common/src/services/cipher.service.ts b/libs/common/src/services/cipher.service.ts index a7b54942e2..b93c60d622 100644 --- a/libs/common/src/services/cipher.service.ts +++ b/libs/common/src/services/cipher.service.ts @@ -412,7 +412,7 @@ export class CipherService implements CipherServiceAbstraction { : firstValueFrom(this.settingsService.settings$).then( (settings: AccountSettingsSettings) => { let matches: any[] = []; - settings.equivalentDomains?.forEach((eqDomain: any) => { + settings?.equivalentDomains?.forEach((eqDomain: any) => { if (eqDomain.length && eqDomain.indexOf(domain) >= 0) { matches = matches.concat(eqDomain); } diff --git a/libs/common/src/services/organization/organization.service.ts b/libs/common/src/services/organization/organization.service.ts index 5d936c53ae..b0d7791ec2 100644 --- a/libs/common/src/services/organization/organization.service.ts +++ b/libs/common/src/services/organization/organization.service.ts @@ -6,7 +6,7 @@ import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; export class OrganizationService implements InternalOrganizationServiceAbstraction { - private _organizations = new BehaviorSubject([]); + protected _organizations = new BehaviorSubject([]); organizations$ = this._organizations.asObservable(); diff --git a/libs/common/src/services/policy/policy.service.ts b/libs/common/src/services/policy/policy.service.ts index 757c3b3e05..c20682184b 100644 --- a/libs/common/src/services/policy/policy.service.ts +++ b/libs/common/src/services/policy/policy.service.ts @@ -16,7 +16,7 @@ import { ListResponse } from "../../models/response/list.response"; import { PolicyResponse } from "../../models/response/policy.response"; export class PolicyService implements InternalPolicyServiceAbstraction { - private _policies: BehaviorSubject = new BehaviorSubject([]); + protected _policies: BehaviorSubject = new BehaviorSubject([]); policies$ = this._policies.asObservable(); diff --git a/libs/common/src/services/settings.service.ts b/libs/common/src/services/settings.service.ts index 923bd8970d..4a6986e37f 100644 --- a/libs/common/src/services/settings.service.ts +++ b/libs/common/src/services/settings.service.ts @@ -6,7 +6,7 @@ import { Utils } from "../misc/utils"; import { AccountSettingsSettings } from "../models/domain/account"; export class SettingsService implements SettingsServiceAbstraction { - private _settings: BehaviorSubject = new BehaviorSubject({}); + protected _settings: BehaviorSubject = new BehaviorSubject({}); settings$ = this._settings.asObservable(); diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index 0c1c3b725a..fe787f21c0 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -1,4 +1,5 @@ import { BehaviorSubject, concatMap } from "rxjs"; +import { Jsonify } from "type-fest"; import { LogService } from "../abstractions/log.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; @@ -13,6 +14,7 @@ import { StorageLocation } from "../enums/storageLocation"; import { ThemeType } from "../enums/themeType"; import { UriMatchType } from "../enums/uriMatchType"; import { StateFactory } from "../factories/stateFactory"; +import { Utils } from "../misc/utils"; import { CipherData } from "../models/data/cipher.data"; import { CollectionData } from "../models/data/collection.data"; import { EncryptedOrganizationKeyData } from "../models/data/encrypted-organization-key.data"; @@ -65,13 +67,13 @@ export class StateService< TAccount extends Account = Account > implements StateServiceAbstraction { - private accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({}); + protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({}); accounts$ = this.accountsSubject.asObservable(); - private activeAccountSubject = new BehaviorSubject(null); + protected activeAccountSubject = new BehaviorSubject(null); activeAccount$ = this.activeAccountSubject.asObservable(); - private activeAccountUnlockedSubject = new BehaviorSubject(false); + protected activeAccountUnlockedSubject = new BehaviorSubject(false); activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable(); private hasBeenInited = false; @@ -79,6 +81,9 @@ export class StateService< private accountDiskCache = new Map(); + // default account serializer, must be overridden by child class + protected accountDeserializer = Account.fromJSON as (json: Jsonify) => TAccount; + constructor( protected storageService: AbstractStorageService, protected secureStorageService: AbstractStorageService, @@ -676,7 +681,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - return this.recordToMap(account?.keys?.organizationKeys?.decrypted); + return Utils.recordToMap(account?.keys?.organizationKeys?.decrypted); } async setDecryptedOrganizationKeys( @@ -686,7 +691,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - account.keys.organizationKeys.decrypted = this.mapToRecord(value); + account.keys.organizationKeys.decrypted = Utils.mapToRecord(value); await this.saveAccount( account, this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -774,7 +779,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - return this.recordToMap(account?.keys?.providerKeys?.decrypted); + return Utils.recordToMap(account?.keys?.providerKeys?.decrypted); } async setDecryptedProviderKeys( @@ -784,7 +789,7 @@ export class StateService< const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); - account.keys.providerKeys.decrypted = this.mapToRecord(value); + account.keys.providerKeys.decrypted = Utils.mapToRecord(value); await this.saveAccount( account, this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -2744,7 +2749,7 @@ export class StateService< protected async state(): Promise> { const state = await this.memoryStorageService.get>(keys.state, { - deserializer: (s) => State.fromJSON(s), + deserializer: (s) => State.fromJSON(s, this.accountDeserializer), }); return state; } @@ -2765,50 +2770,6 @@ export class StateService< await this.setState(updatedState); }); } - - private mapToRecord(map: Map): Record { - return map == null ? null : Object.fromEntries(map); - } - - private recordToMap(record: Record): Map { - return record == null ? null : new Map(Object.entries(record)); - } -} - -export function withPrototype( - constructor: new (...args: any[]) => T, - converter: (input: any) => T = (i) => i -): ( - target: any, - propertyKey: string | symbol, - descriptor: PropertyDescriptor -) => { value: (...args: any[]) => Promise } { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - - return { - value: function (...args: any[]) { - const originalResult: Promise = originalMethod.apply(this, args); - - if (!(originalResult instanceof Promise)) { - throw new Error( - `Error applying prototype to stored value -- result is not a promise for method ${String( - propertyKey - )}` - ); - } - - return originalResult.then((result) => { - return result == null || - result.constructor.name === constructor.prototype.constructor.name - ? converter(result as T) - : converter( - Object.create(constructor.prototype, Object.getOwnPropertyDescriptors(result)) as T - ); - }); - }, - }; - }; } function withPrototypeForArrayMembers( @@ -2847,7 +2808,7 @@ function withPrototypeForArrayMembers( return result.map((r) => { return r == null || r.constructor.name === memberConstructor.prototype.constructor.name - ? memberConverter(r) + ? r : memberConverter( Object.create(memberConstructor.prototype, Object.getOwnPropertyDescriptors(r)) );