diff --git a/apps/browser/src/auth/background/service-factories/account-service.factory.ts b/apps/browser/src/auth/background/service-factories/account-service.factory.ts index 759ff8efdd..3a180df156 100644 --- a/apps/browser/src/auth/background/service-factories/account-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/account-service.factory.ts @@ -6,6 +6,10 @@ import { CachedServices, factory, } from "../../../platform/background/service-factories/factory-options"; +import { + GlobalStateProviderInitOptions, + globalStateProviderFactory, +} from "../../../platform/background/service-factories/global-state-provider.factory"; import { LogServiceInitOptions, logServiceFactory, @@ -19,7 +23,8 @@ type AccountServiceFactoryOptions = FactoryOptions; export type AccountServiceInitOptions = AccountServiceFactoryOptions & MessagingServiceInitOptions & - LogServiceInitOptions; + LogServiceInitOptions & + GlobalStateProviderInitOptions; export function accountServiceFactory( cache: { accountService?: AccountService } & CachedServices, @@ -32,7 +37,8 @@ export function accountServiceFactory( async () => new AccountServiceImplementation( await messagingServiceFactory(cache, opts), - await logServiceFactory(cache, opts) + await logServiceFactory(cache, opts), + await globalStateProviderFactory(cache, opts) ) ); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 7aaaf4dff2..c04bef0fd8 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -60,9 +60,11 @@ import { ContainerService } from "@bitwarden/common/platform/services/container. import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -145,6 +147,7 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u import { BrowserStateService } from "../platform/services/browser-state.service"; import { KeyGenerationService } from "../platform/services/key-generation.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; +import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; import { BrowserSendService } from "../services/browser-send.service"; import { BrowserSettingsService } from "../services/browser-settings.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; @@ -225,6 +228,7 @@ export default class MainBackground { deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; authRequestCryptoService: AuthRequestCryptoServiceAbstraction; accountService: AccountServiceAbstraction; + globalStateProvider: GlobalStateProvider; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -279,8 +283,16 @@ export default class MainBackground { new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), new KeyGenerationService(this.cryptoFunctionService) ) - : new MemoryStorageService(); - this.accountService = new AccountServiceImplementation(this.messagingService, this.logService); + : new BackgroundMemoryStorageService(); + this.globalStateProvider = new DefaultGlobalStateProvider( + this.memoryStorageService as BackgroundMemoryStorageService, + this.storageService as BrowserLocalStorageService + ); + this.accountService = new AccountServiceImplementation( + this.messagingService, + this.logService, + this.globalStateProvider + ); this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, diff --git a/apps/browser/src/platform/background/service-factories/global-state-provider.factory.ts b/apps/browser/src/platform/background/service-factories/global-state-provider.factory.ts new file mode 100644 index 0000000000..d20b7a72ad --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/global-state-provider.factory.ts @@ -0,0 +1,33 @@ +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; + +import { CachedServices, FactoryOptions, factory } from "./factory-options"; +import { + DiskStorageServiceInitOptions, + MemoryStorageServiceInitOptions, + observableDiskStorageServiceFactory, + observableMemoryStorageServiceFactory, +} from "./storage-service.factory"; + +type GlobalStateProviderFactoryOptions = FactoryOptions; + +export type GlobalStateProviderInitOptions = GlobalStateProviderFactoryOptions & + MemoryStorageServiceInitOptions & + DiskStorageServiceInitOptions; + +export async function globalStateProviderFactory( + cache: { globalStateProvider?: GlobalStateProvider } & CachedServices, + opts: GlobalStateProviderInitOptions +): Promise { + return factory( + cache, + "globalStateProvider", + opts, + async () => + new DefaultGlobalStateProvider( + await observableMemoryStorageServiceFactory(cache, opts), + await observableDiskStorageServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts index 9de8f1cffc..b83ec74278 100644 --- a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts @@ -1,12 +1,14 @@ import { AbstractMemoryStorageService, AbstractStorageService, + ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { BrowserApi } from "../../browser/browser-api"; import BrowserLocalStorageService from "../../services/browser-local-storage.service"; import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service"; +import { BackgroundMemoryStorageService } from "../../storage/background-memory-storage.service"; import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; @@ -29,6 +31,14 @@ export function diskStorageServiceFactory( ): Promise { return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService()); } +export function observableDiskStorageServiceFactory( + cache: { + diskStorageService?: AbstractStorageService & ObservableStorageService; + } & CachedServices, + opts: DiskStorageServiceInitOptions +): Promise { + return factory(cache, "diskStorageService", opts, () => new BrowserLocalStorageService()); +} export function secureStorageServiceFactory( cache: { secureStorageService?: AbstractStorageService } & CachedServices, @@ -51,3 +61,14 @@ export function memoryStorageServiceFactory( return new MemoryStorageService(); }); } + +export function observableMemoryStorageServiceFactory( + cache: { + memoryStorageService?: AbstractMemoryStorageService & ObservableStorageService; + } & CachedServices, + opts: MemoryStorageServiceInitOptions +): Promise { + return factory(cache, "memoryStorageService", opts, async () => { + return new BackgroundMemoryStorageService(); + }); +} diff --git a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts index 77267c3e87..60ca9415e0 100644 --- a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts @@ -1,21 +1,20 @@ -import { Observable, mergeMap } from "rxjs"; +import { mergeMap } from "rxjs"; import { AbstractStorageService, - StorageUpdate, + ObservableStorageService, StorageUpdateType, } from "@bitwarden/common/platform/abstractions/storage.service"; import { fromChromeEvent } from "../../browser/from-chrome-event"; -export default abstract class AbstractChromeStorageService implements AbstractStorageService { - constructor(protected chromeStorageApi: chrome.storage.StorageArea) {} +export default abstract class AbstractChromeStorageService + implements AbstractStorageService, ObservableStorageService +{ + updates$; - get valuesRequireDeserialization(): boolean { - return true; - } - get updates$(): Observable { - return fromChromeEvent(this.chromeStorageApi.onChanged).pipe( + constructor(protected chromeStorageApi: chrome.storage.StorageArea) { + this.updates$ = fromChromeEvent(this.chromeStorageApi.onChanged).pipe( mergeMap(([changes]) => { return Object.entries(changes).map(([key, change]) => { // The `newValue` property isn't on the StorageChange object @@ -37,6 +36,10 @@ export default abstract class AbstractChromeStorageService implements AbstractSt ); } + get valuesRequireDeserialization(): boolean { + return true; + } + async get(key: string): Promise { return new Promise((resolve) => { this.chromeStorageApi.get(key, (obj: any) => { diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index 6366c51de3..8f6d519bcf 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -27,22 +27,20 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi private localStorage = new BrowserLocalStorageService(); private sessionStorage = new BrowserMemoryStorageService(); private updatesSubject = new Subject(); + updates$; constructor( private encryptService: EncryptService, private keyGenerationService: AbstractKeyGenerationService ) { super(); + this.updates$ = this.updatesSubject.asObservable(); } get valuesRequireDeserialization(): boolean { return true; } - get updates$() { - return this.updatesSubject.asObservable(); - } - async get(key: string, options?: MemoryStorageOptions): Promise { if (this.cache.has(key)) { return this.cache.get(key) as T; diff --git a/apps/browser/src/platform/storage/background-memory-storage.service.ts b/apps/browser/src/platform/storage/background-memory-storage.service.ts new file mode 100644 index 0000000000..14dadf225e --- /dev/null +++ b/apps/browser/src/platform/storage/background-memory-storage.service.ts @@ -0,0 +1,78 @@ +import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; + +import { BrowserApi } from "../browser/browser-api"; + +import { MemoryStoragePortMessage } from "./port-messages"; +import { portName } from "./port-name"; + +export class BackgroundMemoryStorageService extends MemoryStorageService { + private _ports: chrome.runtime.Port[] = []; + + constructor() { + super(); + + BrowserApi.addListener(chrome.runtime.onConnect, (port) => { + if (port.name !== portName(chrome.storage.session)) { + return; + } + + this._ports.push(port); + + const listenerCallback = this.onMessageFromForeground.bind(this); + port.onDisconnect.addListener(() => { + this._ports.splice(this._ports.indexOf(port), 1); + port.onMessage.removeListener(listenerCallback); + }); + port.onMessage.addListener(listenerCallback); + // Initialize the new memory storage service with existing data + this.sendMessage({ + action: "initialization", + data: Array.from(this.store.keys()), + }); + }); + this.updates$.subscribe((update) => { + this.sendMessage({ + action: "subject_update", + data: update, + }); + }); + } + + private async onMessageFromForeground(message: MemoryStoragePortMessage) { + if (message.originator === "background") { + return; + } + + let result: unknown = null; + + switch (message.action) { + case "get": + case "getBypassCache": + case "has": { + result = await this[message.action](message.key); + break; + } + case "save": + await this.save(message.key, JSON.parse(message.data as string) as unknown); + break; + case "remove": + await this.remove(message.key); + break; + } + + this.sendMessage({ + id: message.id, + key: message.key, + data: JSON.stringify(result), + }); + } + + private async sendMessage(data: Omit) { + this._ports.forEach((port) => { + port.postMessage({ + ...data, + originator: "background", + }); + }); + } +} diff --git a/apps/browser/src/platform/storage/foreground-memory-storage.service.ts b/apps/browser/src/platform/storage/foreground-memory-storage.service.ts new file mode 100644 index 0000000000..ea36c32208 --- /dev/null +++ b/apps/browser/src/platform/storage/foreground-memory-storage.service.ts @@ -0,0 +1,113 @@ +import { Observable, Subject, filter, firstValueFrom, map } from "rxjs"; + +import { + AbstractMemoryStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { fromChromeEvent } from "../browser/from-chrome-event"; + +import { MemoryStoragePortMessage } from "./port-messages"; +import { portName } from "./port-name"; + +export class ForegroundMemoryStorageService extends AbstractMemoryStorageService { + private _port: chrome.runtime.Port; + private _backgroundResponses$: Observable; + private updatesSubject = new Subject(); + + get valuesRequireDeserialization(): boolean { + return true; + } + updates$; + + constructor() { + super(); + + this.updates$ = this.updatesSubject.asObservable(); + + this._port = chrome.runtime.connect({ name: portName(chrome.storage.session) }); + this._backgroundResponses$ = fromChromeEvent(this._port.onMessage).pipe( + map(([message]) => message), + filter((message) => message.originator === "background") + ); + + this._backgroundResponses$ + .pipe( + filter( + (message) => message.action === "subject_update" || message.action === "initialization" + ) + ) + .subscribe((message) => { + switch (message.action) { + case "initialization": + this.handleInitialize(message.data as string[]); // Map entries as array + break; + case "subject_update": + this.handleSubjectUpdate(message.data as StorageUpdate); + break; + default: + throw new Error(`Unknown action: ${message.action}`); + } + }); + } + + async get(key: string): Promise { + return await this.delegateToBackground("get", key); + } + async getBypassCache(key: string): Promise { + return await this.delegateToBackground("getBypassCache", key); + } + async has(key: string): Promise { + return await this.delegateToBackground("has", key); + } + async save(key: string, obj: T): Promise { + await this.delegateToBackground("save", key, obj); + } + async remove(key: string): Promise { + await this.delegateToBackground("remove", key); + } + + private async delegateToBackground( + action: MemoryStoragePortMessage["action"], + key: string, + data?: T + ): Promise { + const id = Utils.newGuid(); + // listen for response before request + const response = firstValueFrom( + this._backgroundResponses$.pipe( + filter((message) => message.id === id), + map((message) => JSON.parse(message.data as string) as T) + ) + ); + + this.sendMessage({ + id: id, + key: key, + action: action, + data: JSON.stringify(data), + }); + + const result = await response; + return result; + } + + private sendMessage(data: Omit) { + this._port.postMessage({ + ...data, + originator: "foreground", + }); + } + + private handleInitialize(data: string[]) { + // TODO: this isn't a save, but we don't have a better indicator for this + data.forEach((key) => { + this.updatesSubject.next({ key, updateType: "save" }); + }); + } + + private handleSubjectUpdate(data: StorageUpdate) { + this.updatesSubject.next(data); + } +} diff --git a/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts b/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts new file mode 100644 index 0000000000..a09d733c6d --- /dev/null +++ b/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts @@ -0,0 +1,61 @@ +import { trackEmissions } from "@bitwarden/common/../spec/utils"; + +import { BackgroundMemoryStorageService } from "./background-memory-storage.service"; +import { ForegroundMemoryStorageService } from "./foreground-memory-storage.service"; +import { mockPort } from "./mock-port.spec-util"; +import { portName } from "./port-name"; + +describe("foreground background memory storage interaction", () => { + let foreground: ForegroundMemoryStorageService; + let background: BackgroundMemoryStorageService; + + beforeEach(() => { + mockPort(portName(chrome.storage.session)); + + background = new BackgroundMemoryStorageService(); + foreground = new ForegroundMemoryStorageService(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test.each(["has", "get", "getBypassCache"])( + "background should respond with the correct value for %s", + async (action: "get" | "has" | "getBypassCache") => { + const key = "key"; + const value = "value"; + background[action] = jest.fn().mockResolvedValue(value); + + const result = await foreground[action](key); + expect(result).toEqual(value); + } + ); + + test("background should call save from foreground", async () => { + const key = "key"; + const value = "value"; + const actionSpy = jest.spyOn(background, "save"); + await foreground.save(key, value); + + expect(actionSpy).toHaveBeenCalledWith(key, value); + }); + + test("background should call remove from foreground", async () => { + const key = "key"; + const actionSpy = jest.spyOn(background, "remove"); + await foreground.remove(key); + + expect(actionSpy).toHaveBeenCalledWith(key); + }); + + test("background updates push to foreground", async () => { + const key = "key"; + const value = "value"; + const updateType = "save"; + const emissions = trackEmissions(foreground.updates$); + await background.save(key, value); + + expect(emissions).toEqual([{ key, updateType }]); + }); +}); diff --git a/apps/browser/src/platform/storage/mock-port.spec-util.ts b/apps/browser/src/platform/storage/mock-port.spec-util.ts new file mode 100644 index 0000000000..0ab5a0ae70 --- /dev/null +++ b/apps/browser/src/platform/storage/mock-port.spec-util.ts @@ -0,0 +1,25 @@ +import { mockDeep } from "jest-mock-extended"; + +/** + * Mocks a chrome.runtime.Port set up to send messages through `postMessage` to `onMessage.addListener` callbacks. + * @returns a mock chrome.runtime.Port + */ +export function mockPort(name: string) { + const port = mockDeep(); + // notify listeners of a new port + (chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => { + port.name = portInfo.name; + (chrome.runtime.onConnect.addListener as jest.Mock).mock.calls.forEach(([callbackFn]) => { + callbackFn(port); + }); + return port; + }); + + // set message broadcast + (port.postMessage as jest.Mock).mockImplementation((message) => { + (port.onMessage.addListener as jest.Mock).mock.calls.forEach(([callbackFn]) => { + callbackFn(message); + }); + }); + return port; +} diff --git a/apps/browser/src/platform/storage/port-messages.d.ts b/apps/browser/src/platform/storage/port-messages.d.ts new file mode 100644 index 0000000000..a64a9b2ef7 --- /dev/null +++ b/apps/browser/src/platform/storage/port-messages.d.ts @@ -0,0 +1,20 @@ +import { + AbstractMemoryStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; + +type MemoryStoragePortMessage = { + id?: string; + key?: string; + /** + * We allow sending a string[] array since it is JSON safe and StorageUpdate since it's + * a simple object with just two properties that are strings. Everything else is expected to + * be JSON-ified. + */ + data: string | string[] | StorageUpdate; + originator: "foreground" | "background"; + action?: + | keyof Pick + | "subject_update" + | "initialization"; +}; diff --git a/apps/browser/src/platform/storage/port-name.ts b/apps/browser/src/platform/storage/port-name.ts new file mode 100644 index 0000000000..a0ece6a410 --- /dev/null +++ b/apps/browser/src/platform/storage/port-name.ts @@ -0,0 +1,12 @@ +export function portName(storageLocation: chrome.storage.StorageArea) { + switch (storageLocation) { + case chrome.storage.local: + return "local"; + case chrome.storage.sync: + return "sync"; + case chrome.storage.session: + return "session"; + default: + throw new Error("Unknown storage location"); + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 8e0d4c288a..0059bcaa8e 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,7 +1,12 @@ import { APP_INITIALIZER, LOCALE_ID, NgModule } from "@angular/core"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; -import { MEMORY_STORAGE, SECURE_STORAGE } from "@bitwarden/angular/services/injection-tokens"; +import { + MEMORY_STORAGE, + OBSERVABLE_DISK_STORAGE, + OBSERVABLE_MEMORY_STORAGE, + SECURE_STORAGE, +} from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { ThemingService } from "@bitwarden/angular/services/theming/theming.service"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; @@ -100,9 +105,11 @@ import { BrowserConfigService } from "../../platform/services/browser-config.ser import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service"; import { BrowserI18nService } from "../../platform/services/browser-i18n.service"; +import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service"; import BrowserMessagingService from "../../platform/services/browser-messaging.service"; import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { BrowserSendService } from "../../services/browser-send.service"; import { BrowserSettingsService } from "../../services/browser-settings.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; @@ -361,7 +368,7 @@ function getBgService(service: keyof MainBackground) { }, { provide: AbstractStorageService, - useFactory: getBgService("storageService"), + useClass: BrowserLocalStorageService, deps: [], }, { provide: AppIdService, useFactory: getBgService("appIdService"), deps: [] }, @@ -444,12 +451,20 @@ function getBgService(service: keyof MainBackground) { { provide: SECURE_STORAGE, useFactory: getBgService("secureStorageService"), - deps: [], }, { provide: MEMORY_STORAGE, useFactory: getBgService("memoryStorageService"), }, + { + provide: OBSERVABLE_MEMORY_STORAGE, + useClass: ForegroundMemoryStorageService, + deps: [], + }, + { + provide: OBSERVABLE_DISK_STORAGE, + useExisting: AbstractStorageService, + }, { provide: StateServiceAbstraction, useFactory: ( diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index a1ecf0785d..2da36f0a5a 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -25,6 +25,7 @@ const runtime = { sendMessage: jest.fn(), getManifest: jest.fn(), getURL: jest.fn((path) => `chrome-extension://id/${path}`), + connect: jest.fn(), onConnect: { addListener: jest.fn(), }, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 191e71c4b8..640b6a194b 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -45,6 +45,9 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; @@ -161,6 +164,7 @@ export class Main { configApiService: ConfigApiServiceAbstraction; configService: CliConfigService; accountService: AccountService; + globalStateProvider: GlobalStateProvider; constructor() { let p = null; @@ -200,7 +204,18 @@ export class Main { this.memoryStorageService = new MemoryStorageService(); - this.accountService = new AccountServiceImplementation(null, this.logService); + this.globalStateProvider = new DefaultGlobalStateProvider( + this.memoryStorageService, + this.storageService + ); + + this.messagingService = new NoopMessagingService(); + + this.accountService = new AccountServiceImplementation( + this.messagingService, + this.logService, + this.globalStateProvider + ); this.stateService = new StateService( this.storageService, @@ -221,7 +236,6 @@ export class Main { this.appIdService = new AppIdService(this.storageService); this.tokenService = new TokenService(this.stateService); - this.messagingService = new NoopMessagingService(); this.environmentService = new EnvironmentService(this.stateService); const customUserAgent = diff --git a/apps/cli/src/platform/services/lowdb-storage.service.ts b/apps/cli/src/platform/services/lowdb-storage.service.ts index d8f8f29412..5e3072ea5e 100644 --- a/apps/cli/src/platform/services/lowdb-storage.service.ts +++ b/apps/cli/src/platform/services/lowdb-storage.service.ts @@ -29,6 +29,7 @@ export class LowdbStorageService implements AbstractStorageService { private defaults: any; private ready = false; private updatesSubject = new Subject(); + updates$; constructor( protected logService: LogService, @@ -38,6 +39,7 @@ export class LowdbStorageService implements AbstractStorageService { private requireLock = false ) { this.defaults = defaults; + this.updates$ = this.updatesSubject.asObservable(); } @sequentialize(() => "lowdbStorageInit") @@ -110,9 +112,6 @@ export class LowdbStorageService implements AbstractStorageService { get valuesRequireDeserialization(): boolean { return true; } - get updates$() { - return this.updatesSubject.asObservable(); - } async get(key: string): Promise { await this.waitForReady(); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 24e3d91ab0..7d5f3615ea 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -7,6 +7,8 @@ import { LOCALES_DIRECTORY, SYSTEM_LANGUAGE, MEMORY_STORAGE, + OBSERVABLE_MEMORY_STORAGE, + OBSERVABLE_DISK_STORAGE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; @@ -102,6 +104,8 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); { provide: AbstractStorageService, useClass: ElectronRendererStorageService }, { provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService }, { provide: MEMORY_STORAGE, useClass: MemoryStorageService }, + { provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE }, + { provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService }, { provide: SystemServiceAbstraction, useClass: SystemService, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 077f4d6270..619e88cfd5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,6 +6,9 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; +import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; +// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; import { MenuMain } from "./main/menu/menu.main"; import { MessagingMain } from "./main/messaging.main"; @@ -85,6 +88,10 @@ export class Main { storageDefaults["global.vaultTimeoutAction"] = "lock"; this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults); this.memoryStorageService = new MemoryStorageService(); + const globalStateProvider = new DefaultGlobalStateProvider( + this.memoryStorageService, + this.storageService + ); // TODO: this state service will have access to on disk storage, but not in memory storage. // If we could get this to work using the stateService singleton that the rest of the app uses we could save @@ -95,7 +102,11 @@ export class Main { this.memoryStorageService, this.logService, new StateFactory(GlobalState, Account), - new AccountServiceImplementation(null, this.logService), // will not broadcast logouts. This is a hack until we can remove messaging dependency + new AccountServiceImplementation( + new NoopMessagingService(), + this.logService, + globalStateProvider + ), // will not broadcast logouts. This is a hack until we can remove messaging dependency false // Do not use disk caching because this will get out of sync with the renderer service ); diff --git a/apps/desktop/src/platform/services/electron-renderer-storage.service.ts b/apps/desktop/src/platform/services/electron-renderer-storage.service.ts index e81a0ca908..86e2c3fffa 100644 --- a/apps/desktop/src/platform/services/electron-renderer-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-renderer-storage.service.ts @@ -11,8 +11,10 @@ export class ElectronRendererStorageService implements AbstractStorageService { get valuesRequireDeserialization(): boolean { return true; } - get updates$() { - return this.updatesSubject.asObservable(); + updates$; + + constructor() { + this.updates$ = this.updatesSubject.asObservable(); } get(key: string): Promise { diff --git a/apps/desktop/src/platform/services/electron-storage.service.ts b/apps/desktop/src/platform/services/electron-storage.service.ts index 065e3f5de0..e9dc066424 100644 --- a/apps/desktop/src/platform/services/electron-storage.service.ts +++ b/apps/desktop/src/platform/services/electron-storage.service.ts @@ -40,6 +40,7 @@ type Options = BaseOptions<"get"> | BaseOptions<"has"> | SaveOptions | BaseOptio export class ElectronStorageService implements AbstractStorageService { private store: ElectronStore; private updatesSubject = new Subject(); + updates$; constructor(dir: string, defaults = {}) { if (!fs.existsSync(dir)) { @@ -50,6 +51,7 @@ export class ElectronStorageService implements AbstractStorageService { name: "data", }; this.store = new Store(storeConfig); + this.updates$ = this.updatesSubject.asObservable(); ipcMain.handle("storageService", (event, options: Options) => { switch (options.action) { @@ -68,9 +70,6 @@ export class ElectronStorageService implements AbstractStorageService { get valuesRequireDeserialization(): boolean { return true; } - get updates$() { - return this.updatesSubject.asObservable(); - } get(key: string): Promise { const val = this.store.get(key) as T; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 4df6557fac..806c3d2a9e 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -8,6 +8,8 @@ import { LOCALES_DIRECTORY, SYSTEM_LANGUAGE, MEMORY_STORAGE, + OBSERVABLE_MEMORY_STORAGE, + OBSERVABLE_DISK_STORAGE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; @@ -74,6 +76,8 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; provide: MEMORY_STORAGE, useClass: MemoryStorageService, }, + { provide: OBSERVABLE_MEMORY_STORAGE, useExisting: MEMORY_STORAGE }, + { provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService }, { provide: PlatformUtilsServiceAbstraction, useClass: WebPlatformUtilsService, diff --git a/apps/web/src/app/core/html-storage.service.ts b/apps/web/src/app/core/html-storage.service.ts index ebcd9241c9..5a7cd2bbc5 100644 --- a/apps/web/src/app/core/html-storage.service.ts +++ b/apps/web/src/app/core/html-storage.service.ts @@ -19,8 +19,10 @@ export class HtmlStorageService implements AbstractStorageService { get valuesRequireDeserialization(): boolean { return true; } - get updates$() { - return this.updatesSubject.asObservable(); + updates$; + + constructor() { + this.updates$ = this.updatesSubject.asObservable(); } get(key: string, options: StorageOptions = this.defaultOptions): Promise { diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index af3350193e..748f1da7ce 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -3,10 +3,17 @@ import { InjectionToken } from "@angular/core"; import { AbstractMemoryStorageService, AbstractStorageService, + ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; export const WINDOW = new InjectionToken("WINDOW"); +export const OBSERVABLE_MEMORY_STORAGE = new InjectionToken< + AbstractMemoryStorageService & ObservableStorageService +>("OBSERVABLE_MEMORY_STORAGE"); +export const OBSERVABLE_DISK_STORAGE = new InjectionToken< + AbstractStorageService & ObservableStorageService +>("OBSERVABLE_DISK_STORAGE"); export const MEMORY_STORAGE = new InjectionToken("MEMORY_STORAGE"); export const SECURE_STORAGE = new InjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new InjectionToken("STATE_FACTORY"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 4c847001bf..1cbb152618 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -101,6 +101,9 @@ import { NoopNotificationsService } from "@bitwarden/common/platform/services/no import { StateService } from "@bitwarden/common/platform/services/state.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service"; import { ApiService } from "@bitwarden/common/services/api.service"; @@ -170,6 +173,8 @@ import { LOG_MAC_FAILURES, LOGOUT_CALLBACK, MEMORY_STORAGE, + OBSERVABLE_DISK_STORAGE, + OBSERVABLE_MEMORY_STORAGE, SECURE_STORAGE, STATE_FACTORY, STATE_SERVICE_USE_CACHE, @@ -337,7 +342,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; { provide: AccountServiceAbstraction, useClass: AccountServiceImplementation, - deps: [MessagingServiceAbstraction, LogService], + deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider], }, { provide: InternalAccountService, @@ -747,6 +752,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; useClass: AuthRequestCryptoServiceImplementation, deps: [CryptoServiceAbstraction], }, + { + provide: GlobalStateProvider, + useClass: DefaultGlobalStateProvider, + deps: [OBSERVABLE_MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 30fe32e259..2fdbfb7830 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -3,12 +3,20 @@ import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; import { AuthenticationStatus } from "../enums/authentication-status"; +/** + * Holds information about an account for use in the AccountService + * if more information is added, be sure to update the equality method. + */ export type AccountInfo = { status: AuthenticationStatus; email: string; name: string | undefined; }; +export function accountInfoEqual(a: AccountInfo, b: AccountInfo) { + return a.status == b.status && a.email == b.email && a.name == b.name; +} + export abstract class AccountService { accounts$: Observable>; activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>; diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index 3b28f39cf1..d6aabef4ee 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -1,9 +1,15 @@ import { MockProxy, mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; import { trackEmissions } from "../../../spec/utils"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { + ACCOUNT_ACCOUNTS, + ACCOUNT_ACTIVE_ACCOUNT_ID, + GlobalState, + GlobalStateProvider, +} from "../../platform/state"; import { UserId } from "../../types/guid"; import { AccountInfo } from "../abstractions/account.service"; import { AuthenticationStatus } from "../enums/authentication-status"; @@ -13,6 +19,11 @@ import { AccountServiceImplementation } from "./account.service"; describe("accountService", () => { let messagingService: MockProxy; let logService: MockProxy; + let globalStateProvider: MockProxy; + let accountsState: MockProxy>>; + let accountsSubject: BehaviorSubject>; + let activeAccountIdState: MockProxy>; + let activeAccountIdSubject: BehaviorSubject; let sut: AccountServiceImplementation; const userId = "userId" as UserId; function userInfo(status: AuthenticationStatus): AccountInfo { @@ -20,10 +31,29 @@ describe("accountService", () => { } beforeEach(() => { - messagingService = mock(); - logService = mock(); + messagingService = mock(); + logService = mock(); + globalStateProvider = mock(); + accountsState = mock(); + activeAccountIdState = mock(); - sut = new AccountServiceImplementation(messagingService, logService); + accountsSubject = new BehaviorSubject>(null); + accountsState.state$ = accountsSubject.asObservable(); + activeAccountIdSubject = new BehaviorSubject(null); + activeAccountIdState.state$ = activeAccountIdSubject.asObservable(); + + globalStateProvider.get.mockImplementation((keyDefinition) => { + switch (keyDefinition) { + case ACCOUNT_ACCOUNTS: + return accountsState; + case ACCOUNT_ACTIVE_ACCOUNT_ID: + return activeAccountIdState; + default: + throw new Error("Unknown key definition"); + } + }); + + sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider); }); afterEach(() => { @@ -39,8 +69,8 @@ describe("accountService", () => { it("should emit the active account and status", async () => { const emissions = trackEmissions(sut.activeAccount$); - sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); - sut.switchAccount(userId); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + activeAccountIdSubject.next(userId); expect(emissions).toEqual([ undefined, // initial value @@ -48,9 +78,21 @@ describe("accountService", () => { ]); }); + it("should update the status if the account status changes", async () => { + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + activeAccountIdSubject.next(userId); + const emissions = trackEmissions(sut.activeAccount$); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) }); + + expect(emissions).toEqual([ + { id: userId, ...userInfo(AuthenticationStatus.Unlocked) }, + { id: userId, ...userInfo(AuthenticationStatus.Locked) }, + ]); + }); + it("should remember the last emitted value", async () => { - sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); - sut.switchAccount(userId); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + activeAccountIdSubject.next(userId); expect(await firstValueFrom(sut.activeAccount$)).toEqual({ id: userId, @@ -59,77 +101,98 @@ describe("accountService", () => { }); }); + describe("accounts$", () => { + it("should maintain an accounts cache", async () => { + expect(await firstValueFrom(sut.accounts$)).toEqual({}); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + expect(await firstValueFrom(sut.accounts$)).toEqual({ + [userId]: userInfo(AuthenticationStatus.Unlocked), + }); + }); + }); + describe("addAccount", () => { it("should emit the new account", () => { - const emissions = trackEmissions(sut.accounts$); sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); - expect(emissions).toEqual([ - {}, // initial value - { [userId]: userInfo(AuthenticationStatus.Unlocked) }, - ]); + expect(accountsState.update).toHaveBeenCalledTimes(1); + const callback = accountsState.update.mock.calls[0][0]; + expect(callback({}, null)).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); }); }); describe("setAccountName", () => { beforeEach(() => { - sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); }); - it("should emit the updated account", () => { - const emissions = trackEmissions(sut.accounts$); + it("should update the account", async () => { sut.setAccountName(userId, "new name"); - expect(emissions).toEqual([ - { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } }, - { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } }, - ]); + const callback = accountsState.update.mock.calls[0][0]; + + expect(callback(accountsSubject.value, null)).toEqual({ + [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" }, + }); + }); + + it("should not update if the name is the same", async () => { + sut.setAccountName(userId, "name"); + + const callback = accountsState.update.mock.calls[0][1].shouldUpdate; + + expect(callback(accountsSubject.value, null)).toBe(false); }); }); describe("setAccountEmail", () => { beforeEach(() => { - sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); }); - it("should emit the updated account", () => { - const emissions = trackEmissions(sut.accounts$); + it("should update the account", () => { sut.setAccountEmail(userId, "new email"); - expect(emissions).toEqual([ - { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } }, - { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } }, - ]); + const callback = accountsState.update.mock.calls[0][0]; + + expect(callback(accountsSubject.value, null)).toEqual({ + [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" }, + }); + }); + + it("should not update if the email is the same", () => { + sut.setAccountEmail(userId, "email"); + + const callback = accountsState.update.mock.calls[0][1].shouldUpdate; + + expect(callback(accountsSubject.value, null)).toBe(false); }); }); describe("setAccountStatus", () => { beforeEach(() => { - sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); }); - it("should not emit if the status is the same", async () => { - const emissions = trackEmissions(sut.accounts$); - sut.setAccountStatus(userId, AuthenticationStatus.Unlocked); - sut.setAccountStatus(userId, AuthenticationStatus.Unlocked); + it("should update the account", () => { + sut.setAccountStatus(userId, AuthenticationStatus.Locked); - expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]); - }); + const callback = accountsState.update.mock.calls[0][0]; - it("should maintain an accounts cache", async () => { - expect(await firstValueFrom(sut.accounts$)).toEqual({ - [userId]: userInfo(AuthenticationStatus.Unlocked), + expect(callback(accountsSubject.value, null)).toEqual({ + [userId]: { + ...userInfo(AuthenticationStatus.Unlocked), + status: AuthenticationStatus.Locked, + }, }); }); - it("should emit if the status is different", () => { - const emissions = trackEmissions(sut.accounts$); - sut.setAccountStatus(userId, AuthenticationStatus.Locked); + it("should not update if the status is the same", () => { + sut.setAccountStatus(userId, AuthenticationStatus.Unlocked); - expect(emissions).toEqual([ - { userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach - { userId: userInfo(AuthenticationStatus.Locked) }, - ]); + const callback = accountsState.update.mock.calls[0][1].shouldUpdate; + + expect(callback(accountsSubject.value, null)).toBe(false); }); it("should emit logout if the status is logged out", () => { @@ -148,34 +211,20 @@ describe("accountService", () => { }); describe("switchAccount", () => { - let emissions: { id: string; status: AuthenticationStatus }[]; - beforeEach(() => { - emissions = []; - sut.activeAccount$.subscribe((value) => emissions.push(value)); + accountsSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); }); it("should emit undefined if no account is provided", () => { - sut.switchAccount(undefined); - - expect(emissions).toEqual([undefined]); + sut.switchAccount(null); + const callback = activeAccountIdState.update.mock.calls[0][0]; + expect(callback(userId, accountsSubject.value)).toBeUndefined(); }); - it("should emit the active account and status", () => { - sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); - sut.switchAccount(userId); - sut.setAccountStatus(userId, AuthenticationStatus.Locked); - sut.switchAccount(undefined); - sut.switchAccount(undefined); - expect(emissions).toEqual([ - undefined, // initial value - { id: userId, ...userInfo(AuthenticationStatus.Unlocked) }, - { id: userId, ...userInfo(AuthenticationStatus.Locked) }, - ]); - }); - - it("should throw if switched to an unknown account", () => { - expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist"); + it("should throw if the account does not exist", () => { + sut.switchAccount("unknown" as UserId); + const callback = activeAccountIdState.update.mock.calls[0][0]; + expect(() => callback(userId, accountsSubject.value)).toThrowError("Account does not exist"); }); }); }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 33388218db..5164d9cf22 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -1,50 +1,80 @@ -import { - BehaviorSubject, - Subject, - combineLatestWith, - map, - distinctUntilChanged, - shareReplay, -} from "rxjs"; +import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs"; +import { Jsonify } from "type-fest"; -import { AccountInfo, InternalAccountService } from "../../auth/abstractions/account.service"; +import { + AccountInfo, + InternalAccountService, + accountInfoEqual, +} from "../../auth/abstractions/account.service"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { + ACCOUNT_ACCOUNTS, + ACCOUNT_ACTIVE_ACCOUNT_ID, + GlobalState, + GlobalStateProvider, +} from "../../platform/state"; import { UserId } from "../../types/guid"; import { AuthenticationStatus } from "../enums/authentication-status"; +export function AccountsDeserializer( + accounts: Jsonify | null> +): Record { + if (accounts == null) { + return {}; + } + + return accounts; +} + export class AccountServiceImplementation implements InternalAccountService { - private accounts = new BehaviorSubject>({}); - private activeAccountId = new BehaviorSubject(undefined); private lock = new Subject(); private logout = new Subject(); + private accountsState: GlobalState>; + private activeAccountIdState: GlobalState; - accounts$ = this.accounts.asObservable(); - activeAccount$ = this.activeAccountId.pipe( - combineLatestWith(this.accounts$), - map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: false }) - ); + accounts$; + activeAccount$; accountLock$ = this.lock.asObservable(); accountLogout$ = this.logout.asObservable(); - constructor(private messagingService: MessagingService, private logService: LogService) {} + + constructor( + private messagingService: MessagingService, + private logService: LogService, + private globalStateProvider: GlobalStateProvider + ) { + this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS); + this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID); + + this.accounts$ = this.accountsState.state$.pipe( + map((accounts) => (accounts == null ? {} : accounts)) + ); + this.activeAccount$ = this.activeAccountIdState.state$.pipe( + combineLatestWith(this.accounts$), + map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: false }) + ); + } addAccount(userId: UserId, accountData: AccountInfo): void { - this.accounts.value[userId] = accountData; - this.accounts.next(this.accounts.value); + this.accountsState.update((accounts) => { + accounts ||= {}; + accounts[userId] = accountData; + return accounts; + }); } setAccountName(userId: UserId, name: string): void { - this.setAccountInfo(userId, { ...this.accounts.value[userId], name }); + this.setAccountInfo(userId, { name }); } setAccountEmail(userId: UserId, email: string): void { - this.setAccountInfo(userId, { ...this.accounts.value[userId], email }); + this.setAccountInfo(userId, { email }); } setAccountStatus(userId: UserId, status: AuthenticationStatus): void { - this.setAccountInfo(userId, { ...this.accounts.value[userId], status }); + this.setAccountInfo(userId, { status }); if (status === AuthenticationStatus.LoggedOut) { this.logout.next(userId); @@ -54,16 +84,22 @@ export class AccountServiceImplementation implements InternalAccountService { } switchAccount(userId: UserId) { - if (userId == null) { - // indicates no account is active - this.activeAccountId.next(undefined); - return; - } + this.activeAccountIdState.update( + (_, accounts) => { + if (userId == null) { + // indicates no account is active + return undefined; + } - if (this.accounts.value[userId] == null) { - throw new Error("Account does not exist"); - } - this.activeAccountId.next(userId); + if (accounts?.[userId] == null) { + throw new Error("Account does not exist"); + } + return userId; + }, + { + combineLatestWith: this.accounts$, + } + ); } // TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow @@ -76,18 +112,26 @@ export class AccountServiceImplementation implements InternalAccountService { } } - private setAccountInfo(userId: UserId, accountInfo: AccountInfo) { - if (this.accounts.value[userId] == null) { - throw new Error("Account does not exist"); + private setAccountInfo(userId: UserId, update: Partial) { + function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo { + return { ...oldAccountInfo, ...update }; } + this.accountsState.update( + (accounts) => { + accounts[userId] = newAccountInfo(accounts[userId]); + return accounts; + }, + { + // Avoid unnecessary updates + // TODO: Faster comparison, maybe include a hash on the objects? + shouldUpdate: (accounts) => { + if (accounts?.[userId] == null) { + throw new Error("Account does not exist"); + } - // Avoid unnecessary updates - // TODO: Faster comparison, maybe include a hash on the objects? - if (JSON.stringify(this.accounts.value[userId]) === JSON.stringify(accountInfo)) { - return; - } - - this.accounts.value[userId] = accountInfo; - this.accounts.next(this.accounts.value); + return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId])); + }, + } + ); } } diff --git a/libs/common/src/platform/abstractions/storage.service.ts b/libs/common/src/platform/abstractions/storage.service.ts index c0e3478f54..f380420c39 100644 --- a/libs/common/src/platform/abstractions/storage.service.ts +++ b/libs/common/src/platform/abstractions/storage.service.ts @@ -8,14 +8,17 @@ export type StorageUpdate = { updateType: StorageUpdateType; }; -export abstract class AbstractStorageService { - abstract get valuesRequireDeserialization(): boolean; +export interface ObservableStorageService { /** * Provides an {@link Observable} that represents a stream of updates that * have happened in this storage service or in the storage this service provides * an interface to. */ - abstract get updates$(): Observable; + get updates$(): Observable; +} + +export abstract class AbstractStorageService { + abstract get valuesRequireDeserialization(): boolean; abstract get(key: string, options?: StorageOptions): Promise; abstract has(key: string, options?: StorageOptions): Promise; abstract save(key: string, obj: T, options?: StorageOptions): Promise; diff --git a/libs/common/src/platform/services/memory-storage.service.ts b/libs/common/src/platform/services/memory-storage.service.ts index f3ad25d3a0..233cb6e7cb 100644 --- a/libs/common/src/platform/services/memory-storage.service.ts +++ b/libs/common/src/platform/services/memory-storage.service.ts @@ -3,7 +3,7 @@ import { Subject } from "rxjs"; import { AbstractMemoryStorageService, StorageUpdate } from "../abstractions/storage.service"; export class MemoryStorageService extends AbstractMemoryStorageService { - private store = new Map(); + protected store = new Map(); private updatesSubject = new Subject(); get valuesRequireDeserialization(): boolean { diff --git a/libs/common/src/platform/state/implementations/default-global-state.provider.ts b/libs/common/src/platform/state/implementations/default-global-state.provider.ts index a00615ddef..3d0498406a 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.provider.ts @@ -1,6 +1,7 @@ import { AbstractMemoryStorageService, AbstractStorageService, + ObservableStorageService, } from "../../abstractions/storage.service"; import { GlobalState } from "../global-state"; import { GlobalStateProvider } from "../global-state.provider"; @@ -13,8 +14,8 @@ export class DefaultGlobalStateProvider implements GlobalStateProvider { private globalStateCache: Record> = {}; constructor( - private memoryStorage: AbstractMemoryStorageService, - private diskStorage: AbstractStorageService + private memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + private diskStorage: AbstractStorageService & ObservableStorageService ) {} get(keyDefinition: KeyDefinition): GlobalState { diff --git a/libs/common/src/platform/state/implementations/default-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts index d6713e14bf..60a61840b0 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -10,7 +10,10 @@ import { timeout, } from "rxjs"; -import { AbstractStorageService } from "../../abstractions/storage.service"; +import { + AbstractStorageService, + ObservableStorageService, +} from "../../abstractions/storage.service"; import { GlobalState } from "../global-state"; import { KeyDefinition, globalKeyBuilder } from "../key-definition"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; @@ -29,7 +32,7 @@ export class DefaultGlobalState implements GlobalState { constructor( private keyDefinition: KeyDefinition, - private chosenLocation: AbstractStorageService + private chosenLocation: AbstractStorageService & ObservableStorageService ) { this.storageKey = globalKeyBuilder(this.keyDefinition); diff --git a/libs/common/src/platform/state/implementations/default-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-user-state.provider.ts index c50e07cc07..fdf46e64a0 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-user-state.provider.ts @@ -3,6 +3,7 @@ import { EncryptService } from "../../abstractions/encrypt.service"; import { AbstractMemoryStorageService, AbstractStorageService, + ObservableStorageService, } from "../../abstractions/storage.service"; import { KeyDefinition } from "../key-definition"; import { StorageLocation } from "../state-definition"; @@ -17,8 +18,8 @@ export class DefaultUserStateProvider implements UserStateProvider { constructor( protected accountService: AccountService, protected encryptService: EncryptService, - protected memoryStorage: AbstractMemoryStorageService, - protected diskStorage: AbstractStorageService + protected memoryStorage: AbstractMemoryStorageService & ObservableStorageService, + protected diskStorage: AbstractStorageService & ObservableStorageService ) {} get(keyDefinition: KeyDefinition): UserState { diff --git a/libs/common/src/platform/state/implementations/default-user-state.ts b/libs/common/src/platform/state/implementations/default-user-state.ts index 19a2c420d0..08dc70e694 100644 --- a/libs/common/src/platform/state/implementations/default-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-user-state.ts @@ -15,7 +15,10 @@ import { import { AccountService } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; import { EncryptService } from "../../abstractions/encrypt.service"; -import { AbstractStorageService } from "../../abstractions/storage.service"; +import { + AbstractStorageService, + ObservableStorageService, +} from "../../abstractions/storage.service"; import { DerivedUserState } from "../derived-user-state"; import { KeyDefinition, userKeyBuilder } from "../key-definition"; import { StateUpdateOptions, populateOptionsWithDefault } from "../state-update-options"; @@ -40,7 +43,7 @@ export class DefaultUserState implements UserState { protected keyDefinition: KeyDefinition, private accountService: AccountService, private encryptService: EncryptService, - private chosenStorageLocation: AbstractStorageService + private chosenStorageLocation: AbstractStorageService & ObservableStorageService ) { this.formattedKey$ = this.accountService.activeAccount$.pipe( map((account) => diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 92f2ed2dbf..bab0bec90f 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -1,3 +1,7 @@ export { DerivedUserState } from "./derived-user-state"; -export { DefaultGlobalStateProvider } from "./implementations/default-global-state.provider"; -export { DefaultUserStateProvider } from "./implementations/default-user-state.provider"; +export { GlobalState } from "./global-state"; +export { GlobalStateProvider } from "./global-state.provider"; +export { UserState } from "./user-state"; +export { UserStateProvider } from "./user-state.provider"; + +export * from "./key-definitions"; diff --git a/libs/common/src/platform/state/key-definitions.ts b/libs/common/src/platform/state/key-definitions.ts new file mode 100644 index 0000000000..50112137e5 --- /dev/null +++ b/libs/common/src/platform/state/key-definitions.ts @@ -0,0 +1,18 @@ +import { AccountInfo } from "../../auth/abstractions/account.service"; +import { AccountsDeserializer } from "../../auth/services/account.service"; +import { UserId } from "../../types/guid"; + +import { KeyDefinition } from "./key-definition"; +import { StateDefinition } from "./state-definition"; + +const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); +export const ACCOUNT_ACCOUNTS = new KeyDefinition>( + ACCOUNT_MEMORY, + "accounts", + { + deserializer: (obj) => AccountsDeserializer(obj), + } +); +export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", { + deserializer: (id: UserId) => id, +}); diff --git a/tsconfig.json b/tsconfig.json index f6f6b4ee30..03404b581b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,5 +34,5 @@ "useDefineForClassFields": false }, "include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"], - "exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts"] + "exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts", "**/*.spec-util.ts"] }