diff --git a/apps/browser/src/platform/state/background-derived-state.provider.ts b/apps/browser/src/platform/state/background-derived-state.provider.ts new file mode 100644 index 00000000000..cbc5a34b37b --- /dev/null +++ b/apps/browser/src/platform/state/background-derived-state.provider.ts @@ -0,0 +1,23 @@ +import { Observable } from "rxjs"; + +import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client +import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider"; +import { DerivedStateDependencies } from "@bitwarden/common/src/types/state"; + +import { BackgroundDerivedState } from "./background-derived-state"; + +export class BackgroundDerivedStateProvider extends DefaultDerivedStateProvider { + override buildDerivedState( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return new BackgroundDerivedState( + parentState$, + deriveDefinition, + deriveDefinition.buildCacheKey(), + dependencies, + ); + } +} diff --git a/apps/browser/src/platform/state/background-derived-state.ts b/apps/browser/src/platform/state/background-derived-state.ts new file mode 100644 index 00000000000..61768cb970c --- /dev/null +++ b/apps/browser/src/platform/state/background-derived-state.ts @@ -0,0 +1,107 @@ +import { Observable, Subscription, concatMap } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DeriveDefinition } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client +import { DefaultDerivedState } from "@bitwarden/common/platform/state/implementations/default-derived-state"; +import { DerivedStateDependencies } from "@bitwarden/common/types/state"; + +import { BrowserApi } from "../browser/browser-api"; + +export class BackgroundDerivedState< + TFrom, + TTo, + TDeps extends DerivedStateDependencies, +> extends DefaultDerivedState { + private portSubscriptions: Map = new Map(); + + constructor( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + portName: string, + dependencies: TDeps, + ) { + super(parentState$, deriveDefinition, dependencies); + + // listen for foreground derived states to connect + BrowserApi.addListener(chrome.runtime.onConnect, (port) => { + if (port.name !== portName) { + return; + } + + const listenerCallback = this.onMessageFromForeground.bind(this); + port.onDisconnect.addListener(() => { + this.portSubscriptions.get(port)?.unsubscribe(); + this.portSubscriptions.delete(port); + port.onMessage.removeListener(listenerCallback); + }); + port.onMessage.addListener(listenerCallback); + + const stateSubscription = this.state$ + .pipe( + concatMap(async (state) => { + await this.sendMessage( + { + action: "nextState", + data: JSON.stringify(state), + id: Utils.newGuid(), + }, + port, + ); + }), + ) + .subscribe(); + + this.portSubscriptions.set(port, stateSubscription); + }); + } + + private async onMessageFromForeground(message: DerivedStateMessage, port: chrome.runtime.Port) { + if (message.originator === "background") { + return; + } + + switch (message.action) { + case "nextState": { + const dataObj = JSON.parse(message.data) as Jsonify; + const data = this.deriveDefinition.deserialize(dataObj); + await this.forceValue(data); + await this.sendResponse( + message, + { + action: "resolve", + }, + port, + ); + break; + } + } + } + + private async sendResponse( + originalMessage: DerivedStateMessage, + response: Omit, + port: chrome.runtime.Port, + ) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.sendMessage( + { + ...response, + id: originalMessage.id, + }, + port, + ); + } + + private async sendMessage( + message: Omit, + port: chrome.runtime.Port, + ) { + port.postMessage({ + ...message, + originator: "background", + }); + } +} diff --git a/apps/browser/src/platform/state/derived-state-interactions.spec.ts b/apps/browser/src/platform/state/derived-state-interactions.spec.ts new file mode 100644 index 00000000000..058b03b39d1 --- /dev/null +++ b/apps/browser/src/platform/state/derived-state-interactions.spec.ts @@ -0,0 +1,118 @@ +/** + * need to update test environment so structuredClone works appropriately + * @jest-environment ../../libs/shared/test.environment.ts + */ + +import { OperatorFunction, Subject, firstValueFrom, identity } from "rxjs"; + +import { DeriveDefinition } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition +import { StateDefinition } from "@bitwarden/common/platform/state/state-definition"; +import { awaitAsync, trackEmissions, ObservableTracker } from "@bitwarden/common/spec"; + +import { mockPorts } from "../../../spec/mock-port.spec-util"; + +import { BackgroundDerivedState } from "./background-derived-state"; +import { ForegroundDerivedState } from "./foreground-derived-state"; + +const stateDefinition = new StateDefinition("test", "memory"); +const deriveDefinition = new DeriveDefinition(stateDefinition, "test", { + derive: (dateString: string) => (dateString == null ? null : new Date(dateString)), + deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)), + cleanupDelayMs: 1000, +}); + +// Mock out the runInsideAngular operator so we don't have to deal with zone.js +jest.mock("../browser/run-inside-angular.operator", () => { + return { + runInsideAngular: (ngZone: any) => (source: any) => source, + }; +}); + +describe("foreground background derived state interactions", () => { + let foreground: ForegroundDerivedState; + let background: BackgroundDerivedState>; + let parentState$: Subject; + const initialParent = "2020-01-01"; + const portName = "testPort"; + + beforeEach(() => { + mockPorts(); + parentState$ = new Subject(); + + background = new BackgroundDerivedState(parentState$, deriveDefinition, portName, {}); + foreground = new ForegroundDerivedState( + deriveDefinition, + portName, + identity as OperatorFunction, + ); + }); + + afterEach(() => { + parentState$.complete(); + jest.resetAllMocks(); + }); + + it("should connect between foreground and background", async () => { + const foregroundEmissions = trackEmissions(foreground.state$); + const backgroundEmissions = trackEmissions(background.state$); + + parentState$.next(initialParent); + await awaitAsync(10); + + expect(backgroundEmissions).toEqual([new Date(initialParent)]); + expect(foregroundEmissions).toEqual([new Date(initialParent)]); + }); + + it("should initialize a late-connected foreground", async () => { + const newForeground = new ForegroundDerivedState(deriveDefinition, portName, identity); + const backgroundTracker = new ObservableTracker(background.state$); + parentState$.next(initialParent); + const foregroundTracker = new ObservableTracker(newForeground.state$); + + expect(await backgroundTracker.expectEmission()).toEqual(new Date(initialParent)); + expect(await foregroundTracker.expectEmission()).toEqual(new Date(initialParent)); + }); + + describe("forceValue", () => { + it("should force the value to the background", async () => { + const dateString = "2020-12-12"; + const emissions = trackEmissions(background.state$); + + await foreground.forceValue(new Date(dateString)); + await awaitAsync(); + + expect(emissions).toEqual([new Date(dateString)]); + }); + + it("should not create new ports if already connected", async () => { + // establish port with subscription + trackEmissions(foreground.state$); + + const connectMock = chrome.runtime.connect as jest.Mock; + const initialConnectCalls = connectMock.mock.calls.length; + + expect(foreground["port"]).toBeDefined(); + const newDate = new Date(); + await foreground.forceValue(newDate); + await awaitAsync(); + + expect(connectMock.mock.calls.length).toBe(initialConnectCalls); + expect(await firstValueFrom(background.state$)).toEqual(newDate); + }); + + it("should create a port if not connected", async () => { + const connectMock = chrome.runtime.connect as jest.Mock; + const initialConnectCalls = connectMock.mock.calls.length; + + expect(foreground["port"]).toBeUndefined(); + const newDate = new Date(); + await foreground.forceValue(newDate); + await awaitAsync(); + + expect(connectMock.mock.calls.length).toBe(initialConnectCalls + 1); + expect(foreground["port"]).toBeNull(); + expect(await firstValueFrom(background.state$)).toEqual(newDate); + }); + }); +}); diff --git a/apps/browser/src/platform/state/foreground-derived-state.provider.ts b/apps/browser/src/platform/state/foreground-derived-state.provider.ts new file mode 100644 index 00000000000..bcf0a0158a8 --- /dev/null +++ b/apps/browser/src/platform/state/foreground-derived-state.provider.ts @@ -0,0 +1,25 @@ +import { Observable, OperatorFunction } from "rxjs"; + +import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- extending this class for this client +import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider"; +import { DerivedStateDependencies } from "@bitwarden/common/src/types/state"; + +import { ForegroundDerivedState } from "./foreground-derived-state"; + +export class ForegroundDerivedStateProvider extends DefaultDerivedStateProvider { + constructor(private pipeCustomizer: OperatorFunction) { + super(); + } + override buildDerivedState( + _parentState$: Observable, + deriveDefinition: DeriveDefinition, + _dependencies: TDeps, + ): DerivedState { + return new ForegroundDerivedState( + deriveDefinition, + deriveDefinition.buildCacheKey(), + this.pipeCustomizer as OperatorFunction, + ); + } +} diff --git a/apps/browser/src/platform/state/foreground-derived-state.spec.ts b/apps/browser/src/platform/state/foreground-derived-state.spec.ts new file mode 100644 index 00000000000..f6e83156d9a --- /dev/null +++ b/apps/browser/src/platform/state/foreground-derived-state.spec.ts @@ -0,0 +1,56 @@ +import { awaitAsync } from "@bitwarden/common/../spec"; +import { OperatorFunction, identity } from "rxjs"; + +import { DeriveDefinition } from "@bitwarden/common/platform/state"; +// eslint-disable-next-line import/no-restricted-paths -- needed to define a derive definition +import { StateDefinition } from "@bitwarden/common/platform/state/state-definition"; + +import { mockPorts } from "../../../spec/mock-port.spec-util"; + +import { ForegroundDerivedState } from "./foreground-derived-state"; + +const stateDefinition = new StateDefinition("test", "memory"); +const deriveDefinition = new DeriveDefinition(stateDefinition, "test", { + derive: (dateString: string) => (dateString == null ? null : new Date(dateString)), + deserializer: (dateString: string) => (dateString == null ? null : new Date(dateString)), + cleanupDelayMs: 1, +}); + +describe("ForegroundDerivedState", () => { + let sut: ForegroundDerivedState; + const portName = "testPort"; + + beforeEach(() => { + mockPorts(); + sut = new ForegroundDerivedState( + deriveDefinition, + portName, + identity as OperatorFunction, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should not connect a port until subscribed", async () => { + expect(sut["port"]).toBeUndefined(); + const subscription = sut.state$.subscribe(); + + expect(sut["port"]).toBeDefined(); + subscription.unsubscribe(); + }); + + it("should disconnect its port when unsubscribed", async () => { + const subscription = sut.state$.subscribe(); + + expect(sut["port"]).toBeDefined(); + const disconnectSpy = jest.spyOn(sut["port"], "disconnect"); + subscription.unsubscribe(); + // wait for the cleanup delay + await awaitAsync(deriveDefinition.cleanupDelayMs * 2); + + expect(disconnectSpy).toHaveBeenCalled(); + expect(sut["port"]).toBeNull(); + }); +}); diff --git a/apps/browser/src/platform/state/foreground-derived-state.ts b/apps/browser/src/platform/state/foreground-derived-state.ts new file mode 100644 index 00000000000..79961b1af00 --- /dev/null +++ b/apps/browser/src/platform/state/foreground-derived-state.ts @@ -0,0 +1,114 @@ +import { + Observable, + OperatorFunction, + ReplaySubject, + defer, + filter, + firstValueFrom, + map, + of, + share, + switchMap, + tap, + timer, +} from "rxjs"; +import { Jsonify } from "type-fest"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DeriveDefinition, DerivedState } from "@bitwarden/common/platform/state"; +import { DerivedStateDependencies } from "@bitwarden/common/types/state"; + +import { fromChromeEvent } from "../browser/from-chrome-event"; + +export class ForegroundDerivedState implements DerivedState { + private port: chrome.runtime.Port; + private backgroundResponses$: Observable; + state$: Observable; + + constructor( + private deriveDefinition: DeriveDefinition, + private portName: string, + private pipeCustomizer: OperatorFunction, + ) { + const latestValueFromPort$ = (port: chrome.runtime.Port) => { + return fromChromeEvent(port.onMessage).pipe( + map(([message]) => message as DerivedStateMessage), + filter((message) => message.originator === "background" && message.action === "nextState"), + map((message) => { + const json = JSON.parse(message.data) as Jsonify; + return this.deriveDefinition.deserialize(json); + }), + ); + }; + + this.state$ = defer(() => of(this.initializePort())).pipe( + switchMap(() => latestValueFromPort$(this.port)), + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => + timer(this.deriveDefinition.cleanupDelayMs).pipe(tap(() => this.tearDownPort())), + }), + this.pipeCustomizer, + ); + } + + async forceValue(value: TTo): Promise { + let cleanPort = false; + if (this.port == null) { + this.initializePort(); + cleanPort = true; + } + await this.delegateToBackground("nextState", value); + if (cleanPort) { + this.tearDownPort(); + } + return value; + } + + private initializePort() { + if (this.port != null) { + return; + } + + this.port = chrome.runtime.connect({ name: this.portName }); + + this.backgroundResponses$ = fromChromeEvent(this.port.onMessage).pipe( + map(([message]) => message as DerivedStateMessage), + filter((message) => message.originator === "background"), + ); + return this.backgroundResponses$; + } + + private async delegateToBackground(action: DerivedStateActions, data: TTo): Promise { + const id = Utils.newGuid(); + // listen for response before request + const response = firstValueFrom( + this.backgroundResponses$.pipe(filter((message) => message.id === id)), + ); + + this.sendMessage({ + id, + action, + data: JSON.stringify(data), + }); + + await response; + } + + private sendMessage(message: Omit) { + this.port.postMessage({ + ...message, + originator: "foreground", + }); + } + + private tearDownPort() { + if (this.port == null) { + return; + } + + this.port.disconnect(); + this.port = null; + this.backgroundResponses$ = null; + } +}